TaeHee Kim
Software Engineer / Bassist
김태희 노경모 한근택
잘못쓰면 Hook간다..
다 설명하기엔 시간이 모자라요..
프로젝트를 시작한지 얼마 지나지 않았을 때, PM의 명을 받음.
주의: 실제로 PM은 이런분이 아닙니다.
Membership에 관한 page와 api를 만들어주세요
다들 한번쯤은 경험해보신 그것
회원가입, 로그인, 회원정보, 패스워드 찾기 등등...
하지만, 만들다 보면
의외로 손이 많이 갑니다.
생각할 것도 많죠.
User와 interaction
입력한 data validation
Server의 response에 대한 처리 등등
처음에는, 익숙한 형태의 class based component를 생각했습니다.
class SignupPageExample extends Component {
state = {
email: '',
password: '',
passwordConfirm: '',
emailErrorMessage: '',
passwordErrorMessage: '',
passwordConfirmErrorMessage: ''
}
handleChange = (e) => {
this.setState({
// e.target에 맞는 value change
// value를 validate하여 적절한 error message change
})
}
render() {
return (
<div className="SigninPageExample">
<input />
<p>{emailErrorMessage}</p>
<input />
<p>{passwordErrorMessage}</p>
<input />
<p>{passwordConfirmErrorMessage}</p>
</div>
)
}
}
export default SigninPageExample
Component를 만들어 놓고 zeplin을 보았습니다.
비슷한 기능과 비슷한 느낌의 페이지들
가입, 로그인, 유저 정보 수정, 패스워드 리셋 등
생각해 보면, 당연한 결과
게으른 개발자는 반복을 싫어합니다.
state = {
// 그 페이지에 맞는 state들
// 그 state에 대한 error들
}
handleChange = (e) => {
this.setState({
// e.target에 맞는 value change
// value를 validate하여 적절한 error message change
})
}
어떻게 하면 반복을 줄일 수 있을까?
특히 이 부분
이때, 동료들이 hooks도 고민해 보라는
조언을 해주었습니다.
그래서 읽어본 공식문서
React team에게는 미안하지만,
솔직히 와닿지 않았어요.
상태관련 로직을
계층구조와 상관없이
재사용할 수 있다? 🤔
일단 대책없이 처음으로 만들어 본 작은 hooks
import { useState, useEffect } from 'react'
type Validator = (value: string) => string
const useValidator = (defaultValue = '', validator: Validator | null) => {
const [ error, setErrorMessage ] = useState('')
const [ value, setValue ] = useState(defaultValue)
const [ isTouched, setTouchedState ] = useState(false)
useEffect(() => {
if (!validator) { return }
const errorMessage = validator(value)
if (isTouched && (errorMessage !== error)) {
setErrorMessage(errorMessage)
}
// eslint-disable-next-line
}, [value, isTouched])
return {
value,
setValue,
setTouchedState,
error,
isTouched,
}
}
export default useValidator
그리고 적용해 보았습니다.
const SignupPageExample: FC<Props> = () => {
const [userName, setUserName] = useState('')
// getPasswordErrorMessage라는 함수만 잘 만들어 주면
// useValidator가 value와 errorMessage를 알아서 return해 줌.
const password = useValidator('', getPasswordErrorMessage)
const checkPassword = ((p1: string) => (p2: string) => (
p1 !== p2 ? 'passwordNotMatched' : ''
))(password.value)
// 위의 코드와 비교했을 때, validator는 다르지만 마찬가지 방법으로
// useValidator가 value와 errorMessage를 알아서 return해 줌.
const passwordConfirm = useValidator('', checkPassword)
return (
<div className="SignupPageExample">
<input />
<input
value={password.value}
onChange={(e) => { password.setValue(e.target.value) }}
/>
<p>{passwordConfirm.error}</p>
<input
value={passwordConfirm.value}
onChange={(e) => { password.setValue(e.target.value) }}
/>
<p>{passwordConfirm.error}</p>
</div>
)
}
export default SignupPageExample
state = {
userName: '',
password: '',
passwordConfirm: '',
passwordError: '',
passwordConfirmError: '',
}
handleChange = (e) => {
this.setState({
[e.target.name]: e.target.value
[`${e.target.name}Error`]:
validator(e.target.value)
})
}
const [userName, setUserName] = useState('')
const password = useValidator('', validator)
const passwordConfirm = useValidator('', validator)
const {
value,
error,
setValue,
// ...
} = password
제법 깔끔해진 component.
state의 변경 및 validation에 대한 부분이 함수로 분리됨.
// Signin.tsx
const [userName, setUserName] = useState('')
const password = useValidator('', validator)
// Signup.tsx
const userName = usevalidator('', validator)
const password = useValidator('', validator)
const passwordConfirm = useValidator('', validator)
// ResetPassword.tsx
const currentPassword = useValidator('', validator)
const newPassword = useValidator('', validator)
const passwordConfirm = useValidator('', validator)
const useValidator = (defaultValue = '', validator: Validator | null) => {
// ...
return {
value,
setValue,
setTouchedState,
setErrorMessage, // 필요하면 추가로 return
error,
isTouched,
}
}
// Signup.tsx
useFetch('/signup', {
onSuccess: ({ data }: any) => {
// ...
},
onFailure: ({ error }) => {
if (!error) { return }
// 수동으로 error message 설정
username.setErrorMessage(error, 'username')
password.setErrorMessage(error, 'password')
password.setErrorMessage(error, 'passwordConfirm')
},
method: 'post',
})
// Signin.tsx
useFetch('/signin', {
onSuccess: ({ data }: any) => {
// ...
},
onFailure: ({ error }) => {
if (!error) { return }
// 수동으로 error message 설정
username.setErrorMessage(error, 'username')
password.setErrorMessage(error, 'password')
},
method: 'post',
})
이런점이 편했어요.
동일한 state와 기능이 필요한 곳에
직관적으로 사용했습니다.
state와 관련한 기능은
useValidator만 수정하면 됩니다.
이제야 이해가 가는 공식문서
React team에게는 미안하지만,
솔직히 와닿지 않았어요.
상태관련 로직을
계층구조와 상관없이
재사용할 수 있다!
제가 바보였어요. 진짜 미안합니다 제대로 이해 못해서.
import { useState, useEffect, useRef, Dispatch, SetStateAction } from 'react'
type Validator = (value: string) => string
interface ValidatedData {
value: string
setValue: Dispatch<SetStateAction<string>>
setTouchedState: Dispatch<SetStateAction<boolean>>
setErrorMessage: Dispatch<SetStateAction<string>>
error: string
isTouched: boolean
initialize: () => void
}
export const initializeErrorMessage = (...args: ValidatedData[]) => {
args.forEach((data) => data.setErrorMessage(''))
}
const useValidator = (defaultValue = '', validator: Validator | null, isImmediately?: boolean) => {
const [ error, setErrorMessage ] = useState('')
const [ value, setValue ] = useState(defaultValue)
const [ isTouched, setTouchedState ] = useState(false)
const isFirstTouch = useRef(true)
const initialize = () => {
setValue('')
setTouchedState(false)
setErrorMessage('')
}
useEffect(() => {
if (!validator) { return }
const errorMessage = validator(value)
if (isImmediately && (errorMessage !== error)) {
setErrorMessage(errorMessage)
return
}
if (isTouched && (errorMessage !== error)) {
setErrorMessage(errorMessage)
return
}
if (isFirstTouch.current) {
isFirstTouch.current = false
return
}
// eslint-disable-next-line
}, [value, isTouched])
return {
value,
setValue,
setTouchedState,
setErrorMessage,
error,
isTouched,
initialize,
}
}
export default useValidator
혼돈_파괴_망각_증식.ts
생각보다 여러 component의 요구사항 증식을
깔끔하게 처리할 수 있었습니다.
TDD 한 척 하기.
개인적으로 @testing-library/react-hooks 추천합니다.
describe('useValidator.ts', () => {
test('should not return error message when dose not passed validator function.', () => {
// Arrange
const { result } = renderHook(() => useValidator('', null))
// Act
act(() => { result.current.setValue('12345') })
// Assert
expect(result.current.error).toEqual('')
// Act
act(() => {
result.current.setTouchedState(true)
result.current.setValue('')
})
// Assert
expect(result.current.error).toEqual('')
// Act
act(() => {
result.current.setErrorMessage('Teting Error')
})
// Assert
expect(result.current.error).toEqual('Teting Error')
})
})
사실 런칭했더니 formik 2.0에서 useFormik을 지원하기 시작했습니다 핳핳.
이런 친구도 나왔더라구요..
Data Fetch에 관련된 상태와 행동을 한곳에서
import React, { Component } from 'react'
class HookMabeopsa extends Component {
constructor(props) {
super(props)
this.state = Object.freeze({
// Status for pending data
isLoading: false,
isFetched: false,
// Data and related accessories
data: null,
count: null,
hasNext: false,
nextUrl: null,
// Error related
hasError: false,
error: null,
errorCode: null,
})
}
async componentDidMount() {
this.setState({
isLoading: true,
})
const {
data,
count,
nextUrl,
error,
errorCode,
} = await fetch('https://훅마법사')
if (error) {
return this.setState({
hasError: true,
error,
errorCode,
isLoading: false,
})
}
this.setState({
data,
count,
hasNext: !!nextUrl,
nextUrl,
isLoading: false,
isFetched: true,
})
}
async componentDidUpdate(prevProps) {
const { 이전 상태 } = prevProps
const { 지금 상태 } = this.props
if (이전 상태 === 지금 상태) {
return
}
if (이전 상태 === 지금 상태) {
return
}
if (이전 상태 === 지금 상태) {
return
}
const { ... } = await fetch(`https://훅마법사?condition=...`)
if (...) { ... }
this.setState({ ... })
}
export const initialState = {
isLoading: false,
hasError: false,
isFetched: false,
error: null,
errorCode: null,
data: null,
count: null,
hasNext: false,
nextUrl: null,
}
export function reducer(state: FetchState, action: Action) {
switch (action.type) {
case 'FETCH_INIT':
return {
...state,
isLoading: true,
isFetched: false,
hasError: false,
}
case 'FETCH_SUCCESS':
return {
isLoading: false,
isFetched: true,
data: action.payload.data,
...
}
}
}
export default function useFetch<T>(
initialUrl: string | null,
header?: { [key: string]: string },
): [FetchState<T>, Dispatch<SetStateAction<string | null>>] {
const [url, setUrl] = useState(initialUrl)
const [state, dispatch] = useReducer(reducer, initialState)
useEffect(() => {
useEffect(() => {
let didCancel = false
const fetchData = async () => {
if (!url) {
return
}
dispatch({ type: 'FETCH_INIT' })
try {
const result = await getRequest({ url, header })
if (!didCancel) {
const payload = {
data: result,
hasNext: !!res.next,
}
dispatch({ type: 'FETCH_SUCCESS', payload })
}
} catch (error) {
if (!didCancel) {
dispatch({ type: 'FETCH_FAIL', payload: { ... } })
}
}
}
fetchData()
return () => {
didCancel = true
}
}, [url])
return [
{
...state,
data: state.data as T,
},
setUrl,
]
}
import React, { FC, useEffect, useCallback } from 'react'
import { Cat } from '../types/models'
interface Props {
catsUrl: string
}
const CatListPage: FC<Props> = ({ catsUrl }) => {
const [{
isLoading,
isFetched,
hasError,
error,
data,
hasNext,
nextUrl,
}, setCatsUrl] = useFetch<Cat[]>(catsUrl)
...
}
export default CatListPage
const CatListPage: FC<Props> = ({ catsUrl }) => {
const [{
isLoading,
isFetched,
data: { cats },
}] = useFetch<Cat[]>(catsUrl)
return (
<div className="CatListPage">
<Loading visible={isLoading} />
{isFetched && (
<ul>
{cats.map((cat: Cat) => (
<Cat key={cat.id} cat={cat} />
))}
</ul>
)}
</div>
)
}
const CatListPage: FC<Props> = ({ catsUrl }) => {
const [{
isLoading,
isFetched,
data: { cats },
hasNext,
nextUrl,
}, setCatsUrl] = useFetch<Cat[]>(catsUrl)
const handleLoadMore = useCallback(() => {
setCatsUrl(nextUrl)
}, [nextUrl])
return (
<div className="CatListPage">
<Loading visible={isLoading} />
{isFetched && (
<ul>
{cats.map((cat: Cat) => (
<Cat key={cat.id} cat={cat} />
))}
</ul>
)}
{hasNext && <LoadMoreButton onClick={handleLoadMore} />}
</div>
)
}
useEffect가 많아지면 상태 추적이 복잡해짐
const { id, name, age, cutiness } = cat
useEffect(() => {
updateCat({
id,
cutiness,
})
}, [cutiness])
// 내 관심사는 cutiness에만 있는데 id가 missing 이라고 warning..
const [{ data: cat }] = useFetch<Cat>(catUrl)
const { name, age, cutiness, hairColor } = cat
useEffect(() => {
...
}, [name])
useEffect(() => {
...
}, [age])
useEffect(() => {
...
}, [cutiness])
useEffect(() => {
...
}, [hairColor])
...
useEffect(() => {
const { data: fetchedEpisode, isFetched } = episodeState
const { data: fetchedProgram } = programState
if (fetchedEpisode && fetchedProgram) {
dispatch({
type: ActionTypes.RESET_VIDEO,
})
}
}, [episodeState.data])
useEffect(() => {
const { data: { id } } = episodeState
setFetchPlaybackUrl(fetchPlaybackUrl(id))
}, [episodeState.data])
useEffect(() => {
const {
kind: nextKind,
episodeNumber: nextEpisodeNumber,
} = parseEpisodeIdentities(nextEpisodeIdentitiesString)
...
}, [match.params.episodeIdentitiesString])
useEffect(() => {
if (category) {
setAdvertisementState({ category })
}
}, [category])
useEffect(() => {
pipModeRef.current = pipMode
}, [pipMode])
useEffect(() => {
playerRef.current = player
}, [player, playerReady])
useEffect(() => {
return () => {
if (!pipModeRef.current) {
dispatch({
type: ActionTypes.RESET_VIDEO,
})
}
initializeAdState()
}
}, [])
useEffect(() => {
window.addEventListener('resize', adjustPlayerStyle)
return () => {
window.removeEventListener('resize', adjustPlayerStyle)
}
}, [])
useEffect(() => {
const { hasError, errorCode } = episodeState
...
}, [episodeState.hasError, episodeIdentitiesString])
프로젝트 런칭하고 나니 이런 친구가 나오더군요
기존에는 쓰는 쪽에서 Consumer 래핑을 했어야 했는데 이젠 그럴 필요가 없어졌어요.
import React, { useReducer } from 'react'
import { Action } from '../types/common'
import { getItem } from '../utils/localStorage'
import { logger } from '../utils/misc'
interface State {
pipMode: boolean
theaterMode: boolean
videoId: string | number | null
episodeId: number | null
programId: number | null
player: any
playerReady: boolean
}
interface Props {
children: React.ReactNode
}
const PLAYER_INITIAL_STATE: State = {
pipMode: false,
theaterMode: getItem('theaterMode', false) === 'true',
videoId: null,
episodeId: null,
programId: null,
player: null,
playerReady: false,
}
const ActionTypes = {
PLAYER_READY: 'PLAYER_READY',
RESET_VIDEO: 'RESET_VIDEO',
LOAD_VIDEO: 'LOAD_VIDEO',
ENABLE_PIP: 'ENABLE_PIP',
CHANGE_THEATER_MODE: 'CHANGE_THEATER_MODE',
}
function reducer(state: State, action: Action) {
logger.log(`[PlayerContext Reducer] ${action.type}`, action.payload)
switch (action.type) {
case ActionTypes.PLAYER_READY:
return {
...state,
player: action.payload,
playerReady: true,
}
case ActionTypes.RESET_VIDEO:
return { ...state, ...PLAYER_INITIAL_STATE }
case ActionTypes.LOAD_VIDEO:
return {
...state,
videoId: action.payload.videoId,
episodeId: action.payload.episodeId,
programId: action.payload.programId,
}
case ActionTypes.ENABLE_PIP:
return {
...state,
pipMode: true,
}
case ActionTypes.CHANGE_THEATER_MODE:
return {
...state,
theaterMode: action.payload,
}
default:
throw new Error(`Unknown action. ${action}`)
}
}
export const PlayerContext = React.createContext({
...PLAYER_INITIAL_STATE,
ActionTypes,
dispatch: (action: Action) => {
logger.log(action)
},
})
export function PlayerContextProvider({ children }: Props) {
const [state, dispatch] = useReducer(reducer, PLAYER_INITIAL_STATE)
const {
pipMode,
theaterMode,
videoId,
episodeId,
programId,
player,
playerReady,
} = state
const contextValue = {
ActionTypes,
pipMode,
theaterMode,
videoId,
dispatch,
episodeId,
programId,
player,
playerReady,
}
return (
<PlayerContext.Provider value={contextValue}>
{children}
</PlayerContext.Provider>
)
}
export const PlayerContextConsumer = PlayerContext.Consumer
<Router>
<PlayerContextProvider>
<App />
</PlayerContextProvider>
</Router>
import { useContext } from 'react'
import { PlayerContext } from '../contexts/PlayerContext'
export default function usePlayerContext() {
return useContext(PlayerContext)
}
// component codes...
const {
ActionTypes,
pipMode,
theaterMode,
player,
playerReady,
dispatch
} = usePlayerContext()
// .....
Provider 지옥
const { pipMode } = usePlayerContext()
const pipModeRef = useRef<boolean>(pipMode)
// ...
useRef(() => {
pipModeRef.current = pipMode
}, [pipMode])
전역 상태가 단순할 때에는 크게 문제가 없음
그러나 Context가 늘어날수록 관리하기가 힘듦
개인적인 의견으로는 Redux를 완전히 대체해서 쓰기에는 어느정도 고통이 따를 것이라 사료됩니다.
react-redux에도 hook이 생김
특정 상황에서 에러가 발생하는 경우
모든 Context를 수집해서 에러를 로깅하는 것보다는
에러가 발생한 상황의 전체 store state를 던지는 것이
유의미
언젠가 필요할(지도 모르는) SSR 대비
hooks와 Functional Component를 적절히 조화하면
적은 코드로 많은 일을 할 수 있다.
useEffect에 대해 심도있게 공부하고 쓰자.
간단한 코드에선 괜찮지만, 조금만 복잡해져도
삽질하기 정말 쉽다.
functional component는 그저 함수로 동작한다는 것을 잊지 말자.
클로져 조심하자.
Context와 useContext로 앱 전체 상태를 관리하는 것은
심사숙고하고 결정하자.
By TaeHee Kim
ODK Media의 FE팀에서 프로젝트에 Hooks를 도입하면서 느꼈던 희노애락을 가감없이 공유합니다.