Study
react
Usereducer

useReducer

useReducer 기본 문법

  • 컴포넌트에 reducer를 추가하는 문법

    const [state, dispatch] = useReducer(reducer, initialArg, init?)
  • useReducer를 컴포넌트의 최상위에 호출하고, reducer를 이용해 state를 관리

    import { useReducer } from 'react';
     
    function reducer(state, action) {
      // ...
    }
     
    function MyComponent() {
      const [state, dispatch] = useReducer(reducer, { age: 42 });
      // ...
  • 매개변수

    • reducer
      • state가 어떻게 업데이트 되는지 지정하는 리듀서 함수
      • 반드시 순수함수여야 하며, state와 action을 인수로 받아야 하고, 다음 state를 반환해야 함
    • initialArg
      • 초기 state가 계산되는 값
    • (optional) init
      • 초기 state를 반환하는 초기화 함수
  • 반환 값 : [state, dispatch]

    • state
      • 첫번째 렌더링에서의 state는 init(initialArg) 또는 initialArg로 설정
    • dispatch
      • state를 새로운 값으로 업데이트하고 리렌더링을 일으킴
  • 주의사항

    • 컴포넌트의 최상위 또는 커스텀 훅에서만 호출 가능 (반복문, 조건문 X)
    • 우연한 비순수성을 찾아내기 위해 Strict Mode에서 두 번 호출됨

dispatch

  • useReducer에 의해 반환되는 함수
  • state를 새로운 값으로 업데이트하고 리렌더링을 일으킴
  • 매개변수 : action
    • {type, payload}
    • type 프로퍼티 : action을 정의
    • payload 프로퍼티 : 추가적인 정보를 표현
  • 반환값
    • 어떤 값도 반환하지 않음
  • 주의사항
    • 오직 다음 렌더링에 사용할 state 변수만 업데이트
      • dispatch 함수를 호출한 직후 state 변수를 읽는다면 호출 이전의 최신화되지 않은 값을 참조
    • Object.is 비교를 통해 새롭게 제공된 값과 현재 state를 비교한 값이 같을 경우, React는 컴포넌트와 해당 컴포넌트의 자식 요소들의 리렌더링을 건너뜀
    • React는 state의 업데이트를 batch
      • 배치 업데이트(Batch Update) : 컴포넌트가 여러 번 업데이트 되더라도, 실제 DOM 요소에 변경사항을 적용하는 것을 최소화하여 애플리케이션의 성능을 최적화하는 기능

      • 이벤트 핸들러의 모든 코드가 수행되고 set 함수가 모두 호출된 후에 화면을 업데이트

        ⇒ 하나의 이벤트에 리렌더링이 여러번 일어나는 것을 방지

      • DOM 접근 등 이른 화면 업데이트를 강제해야할 특수한 상황이 있을 경우 flushSync (opens in a new tab)를 사용

useReducer는 언제 사용하는 게 좋을까?

  • state 형태가 복잡할 때

    • 로직이 파편화되면, 코드 가독성이 떨어짐
    • 복잡한 형태의 state를 사전에 정의된 dispatch로만 수정할 수 있게 만들어 줌
      • state에 대한 접근은 컴포넌트에서만 가능
      • state의 업데이트 하는 방법에 대한 dispatch는 컴포넌트 밖에서 제한

    state를 사용하는 로직이를 관리하는 비즈니스 로직을 분리할 수 있어 state를 관리하기가 한결 쉬워짐

  • 분산된 setState 로직을 한 곳에 모으고 싶을 때

    • 성격이 비슷한 여러 개의 state를 묶어 useReducer로
  • 다른 state 기반으로 state를 업데이트할 때

useReducer의 동작

1. 위험한 상태 변경(setter)는 이제 그만!

  • reduce : 기존(현재) 값을 바탕으로 추가 액션
    • ex) [1,2,3].reduce((curSum, a) ⇒ curSum + a, 0)
  • reducer : 기존 값에 무엇인 액션을 해주는 함수
  • 가장 큰 장점은, setter를 직접적으로 노출하지 않으면서 함수를 컴포넌트 외부에 둘 수 있음
const totalPrice = 
	session.cart.map(item => item.price)
		.reduce((tot, price) => tot + price, 0);
     
// ⇒ ⇒ 이것을 활용해서 reducer를 만들면 기존 totalPrice값에 신규 item.price를 더하는 것!!
const addPrice = useReducer( (prePrice, newPrice) => prePrice + newPrice, 0);
const [totalPrice, addPrice]  ⇒ addPrice(100);

2. 상태 관련 함수를 한 곳에

  • reducer : 같은 상태를 변경하는 함수들을 한 곳에 모아 놓은 것
  • dispatch(action) : action을 전달하는 함수
  • 가장 큰 장점은, 컴포넌트 외부에 상태 변경하는 함수(reducer)를 둘 수 있음!!
const **reducer** = (state, action) => {...}
	// action: {type, payload} // payload는 신규 데이터
	
const [state, dispatch] = useReducer(**reducer**, 초깃값)
//or
const [state, dispatch] = useReducer(**reducer**, initArg, initFn);

예시) plusCount, minusCount 함수를 useReducer로!

기존 코드

src/hooks/counter-context.tsx
import { createContext, PropsWithChildren, useContext, useState } from 'react';
 
const defCtx = { count: 0, plusCount: () => {}, minusCount: () => {} };
 
type CounterContextProps = typeof defCtx;
 
const CounterContext = createContext<CounterContextProps>(defCtx);
 
export const CounterProvider = ({ children }: PropsWithChildren) => {
  const [count, setCount] = useState(0);
  const plusCount = () => setCount((preCount) => preCount + 1);
  const minusCount = () => setCount((preCount) => preCount - 1);
  return (
    <CounterContext.Provider value={{ count, plusCount, minusCount }}>
      {children}
    </CounterContext.Provider>
  );
};
 
export const useCounter = () => useContext(CounterContext);

변경 후

src/hooks/counter-context.tsx
import { createContext, PropsWithChildren, useContext, useReducer } from 'react';
 
const defCtx = {
  count: 0,
  plusCount: (_payload?: number) => {},
  minusCount: (_payload?: number) => {},
};
 
type CounterContextProps = typeof defCtx;
 
const CounterContext = createContext<CounterContextProps>(defCtx);
 
type Reducer = { type: string; payload: number };
 
const reducer = (count: number, { type, payload }: Reducer) => {
  if (type === 'plus') return count + payload;
  if (type === 'minus') return count - payload;
  return count;
};
 
export const CounterProvider = ({ children }: PropsWithChildren) => {
  const [count, dispatch] = useReducer(reducer, 0);
  const plusCount = (payload: number = 1) =>
    dispatch({ type: 'plus', payload });
  const minusCount = (payload: number = 1) =>
    dispatch({ type: 'minus', payload });
  return (
    <CounterContext.Provider value={{ count, plusCount, minusCount }}>
      {children}
    </CounterContext.Provider>
  );
};
 
export const useCounter = () => useContext(CounterContext);

예시) login, logout, saveCartItem, removeCartItem 등 모든 함수를 useReducer를 이용하여 통합(reduce)하기

기존 코드

src/hooks/session-context.tsx
import { createContext, PropsWithChildren, useContext, useState } from 'react';
 
const SampleSession = {
  loginUser: null,
  cart: [
    { id: 100, name: '라면', price: 3000 },
    { id: 101, name: '컵라면', price: 2000 },
    { id: 200, name: '파', price: 5000 },
  ],
};
 
type Session = {
  loginUser: { id: number; name: string } | null;
  cart: typeof SampleSession.cart;
};
 
type SessionContextProps = {
  session: Session;
  login: (id: number, name: string) => void;
  logout: () => void;
  saveCartItem: (id: number, name: string, price: number) => void;
  removeCartItem: (itemId: number) => void;
};
 
const defaultSession: SessionContextProps = {
  session: SampleSession,
  login: () => {},
  logout: () => {},
  saveCartItem: () => {},
  removeCartItem: () => {},
};
 
const SessionContext = createContext<SessionContextProps>(defaultSession);
 
export const SessionProvider = ({ children }: PropsWithChildren) => {
  const [session, setSession] = useState<Session>(SampleSession);
 
  const logout = () => {
    setSession({ ...session, loginUser: null });
  };
  const login = (id: number, name: string) => {
    setSession({ ...session, loginUser: { id: id, name: name } });
  };
 
  const removeCartItem = (itemId: number) => {
    setSession({
      ...session,
      cart: session.cart.filter(({ id }) => id !== itemId),
    });
  };
 
  const saveCartItem = (id: number, name: string, price: number) => {
    if (id !== 0) {
      setSession({
        ...session,
        cart: [
          ...session.cart.map((item) =>
            item.id === id ? { id, name, price } : item
          ),
        ],
      });
    } else {
      id = Math.max(...session.cart.map((item) => item.id), 0) + 1;
      setSession({
        ...session,
        cart: [...session.cart, { id, name, price }],
      });
    }
  };
 
  return (
    <SessionContext.Provider
      value={{ session, login, logout, removeCartItem, saveCartItem }}
    >
      {children}
    </SessionContext.Provider>
  );
};
 
export const useSession = () => useContext(SessionContext);
 

변경 후

src/hooks/session-context.tsx
import {
  createContext,
  PropsWithChildren,
  useCallback,
  useContext,
  useLayoutEffect,
  useReducer,
} from 'react';
import useFetch from './fetch-hook';
 
type LoginUser = { id: number; name: string } | null;
type Cart = { id: number; name: string; price: number }[];
type Session = {
  loginUser: LoginUser;
  cart: Cart;
};
 
const SKEY = 'MY-SESSION1';
 
const getStorage = () => {
  const loginUser = JSON.parse(sessionStorage.getItem(SKEY) ?? 'null');
  const cart = JSON.parse(localStorage.getItem(SKEY) ?? '[]');
 
  return { loginUser, cart };
};
 
const setStorage = (session: Session) => {
  // if (!session )
  const { loginUser, cart } = session;
  sessionStorage.setItem(SKEY, JSON.stringify(loginUser));
  localStorage.setItem(SKEY, JSON.stringify(cart));
};
 
type SessionContextProps = {
  session: Session;
  login: (id: number, name: string) => void;
  logout: () => void;
  saveCartItem: (id: number, name: string, price: number) => void;
  removeCartItem: (itemId: number) => void;
};
 
const defaultSession: SessionContextProps = {
  session: { loginUser: null, cart: [] },
  login: () => {},
  logout: () => {},
  saveCartItem: () => {},
  removeCartItem: () => {},
};
 
type Reducer =
  | { type: 'logout'; payload: null }
  | { type: 'login'; payload: LoginUser }
  | { type: 'removeCartItem'; payload: number }
  | { type: 'saveCartItem'; payload: Cart }
  | { type: 'Initialize'; payload: Session };
 
const reducer = (session: Session, { type, payload }: Reducer) => {
  let _session;
  switch (type) {
    case 'logout':
      _session = { ...session, loginUser: null };
      break;
    case 'login':
      _session = { ...session, loginUser: payload };
      break;
    case 'removeCartItem':
      _session = {
        ...session,
        cart: session.cart.filter((item) => item.id !== payload),
      };
      break;
    case 'saveCartItem':
      console.log('🚀  payload:', payload);
 
      _session = {
        ...session,
        cart: payload,
      };
      break;
    case 'Initialize':
      _session = payload;
      break;
    default:
      return session; // bailout(skip)
  }
  setStorage(_session);
  return _session;
};
 
const SessionContext = createContext<SessionContextProps>(defaultSession);
 
export const SessionProvider = ({ children }: PropsWithChildren) => {
  const storage = getStorage();
  const [session, dispatch] = useReducer(reducer, storage);
 
  const data = useFetch<Session>('/data/sample.json');
  useLayoutEffect(() => {
    if (!data) return;
    dispatch({ type: 'Initialize', payload: data });
  }, [data]);
 
  const logout = useCallback(() => {
    dispatch({ type: 'logout', payload: null });
  }, []);
 
  const login = useCallback((id: number, name: string) => {
    dispatch({ type: 'login', payload: { id, name } });
  }, []);
 
  const removeCartItem = useCallback((itemId: number) => {
    dispatch({ type: 'removeCartItem', payload: itemId });
  }, []);
 
  const saveCartItem = useCallback(
    (id: number, name: string, price: number) => {
      let payload;
      if (id !== 0) {
        payload = [
          ...session.cart.map((item) =>
            item.id === id ? { id, name, price } : item
          ),
        ];
      } else {
        id = Math.max(...session.cart.map((item) => item.id), 0) + 1;
        payload = [...session.cart, { id, name, price }];
      }
      dispatch({ type: 'saveCartItem', payload });
    },
    [session]
  );
 
  return (
    <SessionContext.Provider
      value={{
        session,
        login,
        logout,
        saveCartItem,
        removeCartItem,
      }}
    >
      {children}
    </SessionContext.Provider>
  );
};
 
export const useSession = () => useContext(SessionContext);