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를 반환하는 초기화 함수
- reducer
-
반환 값 : [state, dispatch]
- state
- 첫번째 렌더링에서의 state는 init(initialArg) 또는 initialArg로 설정
- dispatch
- state를 새로운 값으로 업데이트하고 리렌더링을 일으킴
- 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)를 사용
-
- 오직 다음 렌더링에 사용할 state 변수만 업데이트함
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);