부두교에 너무 심취하지는 말게 with Hooks

김태희 노경모 한근택

목차

  • 발표자 소개
  • 서비스 소개
  • Data validation with Hooks
  • Data Fetch with Hooks
  • useReducer + useContext

잘못쓰면 Hook간다..

발표자 소개

서비스 소개

프로젝트를

진행하며
만들었던

custom  hooks

기쁨과 고통을

주었던 hooks 몇개를

뽑아 소개합니다.

다 설명하기엔 시간이 모자라요..

부두교에 심취했던 이야기

간단한 useValidator 개발기

프로젝트를 시작한지 얼마 지나지 않았을 때, 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을 지원하기 시작했습니다 핳핳.

이런 친구도 나왔더라구요..

매력적인 useFetch

Data Fetch에 관련된 상태와 행동을 한곳에서

만약

Class based component

로 만들었다면?

State declaration

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,
    })
  }
 

Fetch data &
Update related status

  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,
    })
  }

Compare conditions &
Refetch data &
Update related status

  async componentDidUpdate(prevProps) {
    const { 이전 상태 } = prevProps
    const { 지금 상태 } = this.props

    if (이전 상태 === 지금 상태) {
      return
    }

    if (이전 상태 === 지금 상태) {
      return
    }

    if (이전 상태 === 지금 상태) {
      return
    }

    const { ... } = await fetch(`https://훅마법사?condition=...`)
    if (...) { ... }
    this.setState({ ... })
  }

이 행위를

Data Fetch가 일어나는

모든 Component 마다...

HOC가 구세주이길 바라지만..

결국

  • 시점 컨트롤이 명확한 코드이긴 하지만

  • 정적인 레벨에서 코드량이 다소

  • 경우에 따라 코드를 읽는 시선이

  • 전반적인 복잡도가

드디어 useFetch

프로젝트 초창기에
이런 글을 보게 됐습니다.

useFetch의 구조

  • initialState with useReducer

  • API 주소를 담을 url with useState

  • 이 url이 바뀔때마다 실행될 useEffect

  • useReducer의 state와 setUrl을 return

initialState with useReducer

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,
        ...
      }
  }
}

useFetch 선언부

  • API 주소를 담을 url with useState

  • initialState with useReducer

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(() => {

url이 바뀔때마다 실행될 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])

useFetch 내부 상태와 이를

적절하게 바꿀 수 있는

함수를 함께 반환

  return [
    {
      ...state,
      data: state.data as T,
    },
    setUrl,
  ]
}

그럼 사용할때는?

Functional Component 선언부

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

Data와 Data의 생애주기에 관련된 상태들 중
필요한 것들만 꺼내서 사용

Data 왕래 상태

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>
  )
}

오고있어? 다 온거야? 🤔
이렇게 짧게 할 수 있어요

Paging? or LoadMore?

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>
  )
}

Paging: count를 꺼내서
LoadMore: hasNext, nextUrl을 꺼내서

결과적으로

  • 시점 컨트롤이 명확한 코드이긴 하지만

  • 정적인 레벨에서 코드량이 다소

  • 경우에 따라 코드를 읽는 시선이 ↑ ↓

  • 전반적인 복잡도가

  • 시점 컨트롤이 다소 어려워짐

  • 전반적인 코드량이

  • 대부분 코드를 읽는 시선이 ↓ ↓ 아래로만

  • 사용처에서 전반적인 복잡도가

그렇다고 만병통치약은 아니었어요

  • useEffect가 많아지면 상태 추적이 복잡해짐

  • useEffect의 Dependency Array가 그리 똑똑하진 않음
  • Data를 렌더링하는 방식이 복잡해지면 시점 컨트롤이 어려움
  • 로직 잘못 짜면 렌더링 될 때마다 fetch 호출됨

Annoooooying Dependency Array

const { id, name, age, cutiness } = cat

useEffect(() => {
  updateCat({
    id,
    cutiness,
  })
}, [cutiness])
// 내 관심사는 cutiness에만 있는데 id가 missing 이라고 warning..

충분히 복잡한
Functional Component 의
useEffect 추적

이렇게 간단한 경우에도
만약 이들의 순서가 중요하다면?
값들 간에 의존성이 존재한다면?

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])

프로젝트 런칭하고 나니 이런 친구가 나오더군요

useReducer + useContext

useContext hook이

추가되었습니다.

기존에는 쓰는 쪽에서 Consumer 래핑을 했어야 했는데 이젠 그럴 필요가 없어졌어요.

PlayerContext

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

App을 Context의 Provider로 감싸기

<Router>
  <PlayerContextProvider>
    <App />
  </PlayerContextProvider>
</Router>

useContext로 Context

자체를 감싸기

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()

// .....

이걸로 Redux를

대체해보자!

그리고 찾아온 현실(1)

Provider 지옥

그리고 찾아온 현실(2)

  • 일부 케이스에서 useContext로 가져온 값을
    useEffect 로직에 쓰려고 하니 바뀌기 전의 값을
    계속 참조
    • 클로져 문제로 추정
    • dependency array에 넣을 필요 없는 값인데
      로직에서 쓰이는 경우 useRef, useEffect 처리로
      클로져 문제 회피 가능

그리고 찾아온 현실(2)

const { pipMode } = usePlayerContext() 
const pipModeRef = useRef<boolean>(pipMode)

// ...
useRef(() => {
  pipModeRef.current = pipMode
}, [pipMode])

실제로 써보니

  • 전역 상태가 단순할 때에는 크게 문제가 없음

  • 그러나 Context가 늘어날수록 관리하기가 힘듦

    • 개인적인 의견으로는 Redux를 완전히 대체해서 쓰기에는 어느정도 고통이 따를 것이라 사료됩니다.

그래서 Redux 살릴 예정입니다.

  • react-redux에도 hook이 생김

  • 특정 상황에서 에러가 발생하는 경우
    모든 Context를 수집해서 에러를 로깅하는 것보다는
    에러가 발생한 상황의 전체 store state를 던지는 것이
    유의미

  • 언젠가 필요할(지도 모르는) SSR 대비

시간 관계상 못 다뤘지만

  • 외부 라이브러리 연동할 때는 hooks를 붙이는 것은 좀 더
    심사숙고 해보시길...
    • 외부 플레이어쪽 연동을 React + hooks로 구현하면서 플레이어의 lifecycle 제어를 위해 useEffect를 썼다가 useEffect 쑥대밭이 되어버림
    • 코드 읽기도 힘들고 디버깅은 더 힘듦
    • 차라리 이런 경우에는 class component를 쓰는 게 나을지도 모르겠다는 생각이 듭니다.
    • 여긴 아예 싹 들어내고 비 react 코드로 다시 작성할 예정입니다.

결론

hooks와 Functional Component를 적절히 조화하면
적은 코드로 많은 일을 할 수 있다.

useEffect에 대해 심도있게 공부하고 쓰자.

간단한 코드에선 괜찮지만, 조금만 복잡해져도
삽질하기 정말 쉽다.

functional component는 그저 함수로 동작한다는 것을 잊지 말자.

클로져 조심하자.

Context와 useContext로 앱 전체 상태를 관리하는 것은
심사숙고하고 결정하자.

부두교에 너무 심취하진 말게

Q&A

함께 멋진 서비스를 만들 동료를 찾고있어요

감사합니다!