avatar
Ganymedian

React Project : zustand-and-useContext

7 months ago
·
29 min read

React 에서 전역 상태관리…

React 생태계에서 전역 상태관리는 3rd 파티 라이브러리를 통해 구현하는 게 일반적입니다.

React 에서 상태관리 라이브러리로는 Redux, Recoil, Zustand 등이 널리 사용되고 있는데, 이 중 Zustand 의 보급과 사용이 확산되고 있는 추세인 것 같습니다. (npm 다운로드 Zustand 3.4m/w 으로, Redux 3.5m/w 와 거의 비슷해졌습니다.) State of JS 2023 은 아직이지만 그 비슷한 보고는 많이 접하고 있어요. State of JS 2023 발표에서는 아마도 Zustand 가 꽤 높은 인지도와 추세를 보이게 될 것으로 전망합니다.

Zustand 는 배우고 사용하기가 매우 쉬우면서 빠릿한 느낌입니다. 아마도 누구라도 한시간 정도면 큰 어려움 없이 사용할 수 있게 될 것이라고 생각합니다.

하지만 React 18 의 useReducer 와 useContext 를 사용하면, 별도의 3rd 파티 상태관리 라이브러리가 필요없을 정도로 완벽한 전역상태관리가 가능해집니다.

바닐라 코드가 대체로 그렇듯, 처음엔 조금 복잡해 보일 수도 있는데, 바닐라 코드만의 찌릿찌릿한 매력과 성취가 있습니다. (그리고 애착도 생겨서 이런 비생산적인 글을 생산하게 되기도 하죠.)

저 개인적으로도 Zustand 에 대한 이해를 좀 더 다지고 넘어가고 싶었고, useContext 의 커스텀 훅을 나름 최종판으로 만들어낸 결과물을 보고 싶어서 이 프로젝트를 기획해 봤습니다.

이 프로젝트의 태스크 & 목표

LocalStorage 에서 Todo List 를 전역상태관리 툴로 관리해봅니다.

Zustand 버전으로도 관리하고,

useContext 버전으로도 관리해 봅니다.

Complete Source Code & Working Demo

Working Demo @Vercel

https://zustand-and-usecontext.vercel.app/

Github Repository

https://github.com/KangWoosung/zustand-and-usecontext

StackBlitz

https://stackblitz.com/~/github.com/KangWoosung/zustand-and-usecontext

*첫 접속시 환경 준비에 시간이 좀 많이 걸립니다. 2~3분?

*StackBlitz 는 터미널 창에 커서가 준비되면 npm run dev 커맨드를 입력해줘야 합니다.

*아직 Beta 라서 모든 개발환경에서 안정적인 작동을 하지 않습니다. 현재는 node_module 호환 에러로 커맨드와 미리보기는 작동 안되네요. stackoverflow 에 해당 문제에 대한 해결방법이 있긴 한데, 패스 합니다.

프로젝트 준비

그러면 이제 Zustand 와 useContext 의 코드를 차례차례 만들고 비교해 봅시다.

따라해보실 분들은 이렇게 따라오시면 됩니다.

NextJS 프로젝트 생성

// cd zustand-and-usecontext
npx create-next-app@latest . --typescript --eslint

Tailwind…

npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p

// tailwind.config.ts 를 남겨두고 tailwind.config.js 는 삭제합니다.

ZuStand

npm i zustand

그리고, RHF+Zod+Resolver

npm install react-hook-form
npm install @hookform/resolvers
npm install zod

react-icons

npm i react-icons

shadCN UI

npx shadcn-ui@latest init
npx shadcn-ui@latest add checkbox
//npx shadcn-ui@latest add button

프로젝트 폴더 분기:

[#1] Zustand 버전의 상태관리 라이브러리

[#2] useContext 버전의 상태관리 라이브러리

이렇게 두 가지를 병행하겠습니다.

두 개의 버전을 스위치할 수 있도록 폴더 구조와 네비게이션을 다음과 같이 사용합니다.

page.tsx

-/pages/zustand

-useZustand

-/pages/context

-useContext

BaseLayout

// Zustand, Context 를 스위치 하면서 비교할 수 있도록 레이아웃에 준비해둡니다.
<ul className="flex flex-row justify-center border-solid rounded-lg bg-slate-500 
	text-gray-100">
  <li className=" py-4 px-8">
    <Link href={"/"}>Home</Link>
  </li>
  <li className=" py-4 px-8">
    <Link href={"/zustand"}>Zustand</Link>
  </li>
  <li className=" py-4 px-8">
    <Link href={"/context"}>Context</Link>
  </li>
</ul>

프로젝트 공통 모듈….

  1. 두가지 버전 모두에서 공통으로 사용되는 initial State 와 그 Type 을 먼저 결정합니다.

https://github.com/KangWoosung/zustand-and-usecontext/blob/main/src/app/contexts/types/TodoType.ts

https://github.com/KangWoosung/zustand-and-usecontext/blob/main/src/app/contexts/types/InitialStateType.ts

// 1. Initial Types .. 프로젝트 전역 State 객체의 타입을 결정합니다.
// Type of Todos...
// app/context/types/TodoType.ts
export type TodoType = {
    id: string,
    title: string,
    content: string,
    isCompleted: boolean,
    createdAt: Date,
    updatedAt?: Date,
}
// Type of InitialState
// app/context/types/InitialStateType.ts
export type InitialTodoType = {
  todos: TodoType[];
};
export type InitialContextStateType = {
  currentUser: string | null;
  authChecked: boolean;
  todos: TodoType[];
};
export type InitialContextDispatchType = {
  checkAuth: (check: boolean) => void;
  setCurrentUser: (user: string) => void;
  addTodo: (todo: TodoType) => void;
  deleteTodo: (id: string) => void;
  updateTodo: (id: string, updatedFields: Partial<TodoType>) => void;
  loadTodos: () => void;
};

export type InitialStateType = InitialContextStateType &
  InitialContextDispatchType;
  
  1. Initial state .. 프로젝트 전역 State 객체의 초기값을 결정합니다

https://github.com/KangWoosung/zustand-and-usecontext/blob/main/src/app/data/initialState.ts

// 2. Initial state .. 프로젝트 전역 State 객체의 초기값을 결정합니다.
export const initialState = {
  currentUser: null,
  authChecked: false,
  todos: [],
  checkAuth: () => {}, // 초기 값은 임의로 설정
  setCurrentUser: () => {},
  addTodo: () => {},
  deleteTodo: () => {},
  updateTodo: () => {},
  loadTodos: () => [],
};

1-1. LocalStorage IO 를 위한 helper 펑션

https://github.com/KangWoosung/zustand-and-usecontext/blob/main/src/app/utils/todosIO.ts

// app/utils/todosIO.ts
// zustand.create 펑션 내부에서 호출되어야 하기 때문에
// 커스텀 훅으로는 사용할 수 없다. 원시형 펑션으로 사용해야 한다.
export const loadTodosFromLocalStorage = () => {
  if (typeof window !== "undefined") {
    const storedTodos = localStorage.getItem("todos");
    return storedTodos ? JSON.parse(storedTodos) : [];
  }
};
// Helper function to save Todos to LocalStorage
export const saveTodosToLocalStorage = (todos: TodoType[]) => {
  if (typeof window !== "undefined") {
    localStorage.setItem("todos", JSON.stringify(todos));
  }
};

Zustand 전용 훅

2-1. 다음으로 Zustand 버전의 useTodoStore 훅을 작성합니다.

Zustand 에서는, create 로 커스텀 훅을 생성하고 훅이 리턴하는 객체들을 그냥 가져다 쓰면 됩니다.

전역 객체에서 사용될 값들과, 그 값들을 업데이트 해주는 펑션들을 작성해서 zustand.create 펑션으로 전용 훅을 생성합니다.

시간을 갖고 충분히 살펴보신다면 코드를 이해하기에 큰 어려움은 없을 것 같습니다.

https://github.com/KangWoosung/zustand-and-usecontext/blob/main/src/app/contexts/zustand/hooks/useTodoStore.tsx

// app/context/zustand/hooks/useTodoStore.ts
// 2. Zustand 버전의 useTodoStore  훅
export const useTodoStore = create<InitialStateType>((set, get) => ({
  ...initialState,

  checkAuth: () => {
    const currentUser = get().currentUser;
    return currentUser !== null;
  },

  setCurrentUser: (user: string) => {
    set(() => ({ currentUser: user }));
    return;
  },

  addTodo: (todo: TodoType) => {
    set((state) => {
      const updatedTodos = [...state.todos, todo];
      // console.log(updatedTodos);
      saveTodosToLocalStorage(updatedTodos);
      return { ...state, todos: updatedTodos };
    });
  },

  deleteTodo: (id: string) => {
    set((state) => {
      const updatedTodos = state.todos.filter((todo) => todo.id !== id);
      saveTodosToLocalStorage(updatedTodos);
      return { ...state, todos: updatedTodos };
    });
  },

  updateTodo: (id: string, updatedFields: Partial<TodoType>) => {
    // console.log("updateTodo", id, updatedFields);
    set((state) => {
      const updatedTodos = state.todos.map((todo) =>
        todo.id === id
          ? { ...todo, ...updatedFields, updatedAt: new Date() }
          : todo
      );
      // console.log(updatedTodos);
      saveTodosToLocalStorage(updatedTodos);
      return { ...state, todos: updatedTodos };
    });
  },

  loadTodos: () => {
    const todos = loadTodosFromLocalStorage();
    set(() => ({ todos }));
    return todos;
  },
}));

복수의 create 인스턴스 훅을 사용하면, 지역별, 범주별, 전역 상태객체와 펑션들을 따로 관리할 수도 있습니다. 규칙과 매뉴얼 관리만 잘 이루어진다만 대규모 프로젝트에서도 충분히 사용할 수 있겠죠.

참고로, Zustand 는 독일어로, ‘상태’ 라는 뜻이고 명사라서 der Zustand 대문자로 시작하는 것도 맞습니다. 발음은, [추슈탄트] 라고 읽는 게 맞긴 합니다만, 영어권 개발자 유튜버 중에서 맞게 읽는 사람을 보기 어려워요. 그냥 알아서 읽으시고 알아들을 수 있게 전달만 하시면 될 것 같습니다.

컴포넌트에서 Zustand 훅 사용

2-2. 이제 컴포넌트에서 Zustand 버전의 상태관리 객체를 사용할 수 있습니다.

https://github.com/KangWoosung/zustand-and-usecontext/blob/main/src/app/features/zustand/components/TodoListExport.tsx


import { useTodoStore } from "@/app/contexts/zustand/hooks/useTodoStore";
import React, { useState } from "react";
import TodoList from "@/app/features/components/TodoList";

const TodoListExport = () => {
  // 뭉터기 구독 폐기!!!  2024-06-05 04:37:15
  // const { currentUser, setCurrentUser, loadTodos, deleteTodo, updateTodo } = useTodoStore();

  const { currentUser, setCurrentUser } = useTodoStore();
  const loadTodos = useTodoStore((state) => state.loadTodos);
  const addTodo = useTodoStore((state) => state.addTodo);
  const deleteTodo = useTodoStore((state) => state.deleteTodo);
  const updateTodo = useTodoStore((state) => state.updateTodo);
  const todos = useTodoStore((state) => state.todos);
  const [addMode, setAddMode] = useState<boolean>(false);

  return (
    <TodoList
      currentUser={currentUser}
      setCurrentUser={setCurrentUser}
      loadTodos={loadTodos}
      addTodo={addTodo}
      deleteTodo={deleteTodo}
      updateTodo={updateTodo}
      todos={todos}
    />
  );
};

export default TodoListExport;

이제 프로젝트 어느곳에서든, useTodoStore() 훅으로 필요한 전역 상태 객체와 그 업데이트 펑션을 불러와서 사용하면 됩니다. 너무 간단하죠?

*선택적 구독 Selective Subscription 에 대해서..

리액트 컴포넌트에서 상태의 변화는 컴포넌트의 리랜더링을 발생시키죠. 그런데 하나의 상태 객체의 변화가 다른 상태 객체의 변화를 발생시키고, 이게 다시 연쇄적으로 이어질 때 심각한 문제가 발생합니다.

선택적 구독이란, 상태 관리에서 특정 컴포넌트가 상태의 일부분만 구독하고, 그 일부분이 변경될 때에만 리렌더링되도록 최적화하는 것을 의미합니다. 이러한 최적화는 특히 대규모 애플리케이션에서 성능을 향상시키는 데 중요한 역할을 합니다.

// 이 코드는 위험할 수 있습니다.
const { currentUser, setCurrentUser, loadTodos, deleteTodo, updateTodo } = useTodoStore();

// 선택적 구독으로, 각 상태객체를 batch 열에 하나씩 올려줘야 합니다.
const { currentUser, setCurrentUser } = useTodoStore();
const loadTodos = useTodoStore((state) => state.loadTodos);
const deleteTodo = useTodoStore((state) => state.deleteTodo);
const todos = useTodoStore((state) => state.todos);
const [addMode, setAddMode] = useState<boolean>(false);

이것으로 Zustand 에서의 전역 상태관리 작업은 마무리 되었습니다.

사용 방법도 직관적이지만, 굳이 Scope 를 제한하지 않고 프로젝트 전역에서 손쉽고 자유롭게 불러와서 사용할 수 있다는 게 장점이겠네요. Reducer 와 dispatch 에 대한 사전지식이 없어도 사용할 수 있다는 것도 큰 이점이겠습니다.

useContext 와 Context 의 원형

이제 useContext 에서의 전역 상태관리에 대해 알아봅시다.

useContext 에서는, Context 를 생성하고 Context 제공자, 즉 Context.Provider 가 감싸고 있는 스코프 내부에서 프롭으로 제공되는 값들이 전역 상태 객체가 됩니다.

Context 의 원형은 비교적 단순합니다.

import {createContext, useContext} from "react" 

// 1. Context 생성
const SomeContext = createContext(undefined);

// 2. Context.Provider 생성
  return (
    <SomeContext.Provider value={someValues}>
        {children}
    </SomeContext.Provider>
  );

// 3. Context 소비
//   children 스코프 내에서 이렇게 가져다 사용하면 됩니다.
const someValues= useContext(SomeContext );

Context 의 원형 그대로 사용한다면, 복잡한 문제나, 복잡한 문제를 단순화 하기 위해 더 복잡한 문제를 신경써야 할 필요가 없지만, 일이라는 게 그렇게만 흘러가지는 않죠.

관리해야 할 전역 상태들은 단순한 변수 하나일 리 없고, 또 Zustand 의 코드에서 처럼, 최종 액션 펑션들을 …

const loadTodos = useTodoStore((state) => state.loadTodos);
const deleteTodo = useTodoStore((state) => state.deleteTodo);

이렇게 사용하고 싶어지기 마련입니다.

useContext 의 전역 상태관리 준비

작업 과정은 이렇게 진행될 예정입니다.

1. 전역 State 의 타입 선언
2. 초기 상태 선언
3. Reducer.Action_Types
4. Reducer function
5. useReducer Custom hook function
6. create createContext
7. Context.Provider Component
8. Context.Consumer Custom Hook

한눈에 보기에도 절차가 좀 복잡해 보이죠.

Reducer 파트를 논외로 하고, 핵심적인 내용만 축약하자면 아래 코드와 같습니다,

// 1. createContext
export const IonicContext = createContext<
  ReturnType<typeof useIonicReducer> | undefined
>(undefined);

// 2. context.Provider
//  1 에서 생성한 Context: IonicContext 가 Context.Provicer 로 사용됩니다.
//  여기서 사용되는 prop 들은 useReducer 의 커스텀 훅이 생성해서 리턴해줍니다.
export const IonicTodoProvider = ({ children }: { children: ReactNode }) => {
  const ionicContextValue = useIonicReducer();
  return (
    <IonicContext.Provider value={ionicContextValue}>
      {children}
    </IonicContext.Provider>
  );
};

// 3. context.Consumer function
//  컨텍스트의 프롭들을 보다 간편하게 사용하기 위한 커스텀 훅입니다.
export const useIonicContext = () => {
  const context = useContext(IonicContext);
  if (context === undefined) {
    throw new Error("useIonicContext must be used within a IonicTodoProvider");
  }
  return context;
};

// 4. Context 스코프 설정
return(
	<IonicTodoProvider >
		<App />
	</IonicTodoProvider >
)

// 5. 스코프 내 App.tsx 에서 마음대로 가져다 쓰기
const {currentUser, updateTodo}  = useIonicContext();

그리고, Context.Consumer 커스텀 훅의 역할도 필요한데요..

컨슈머 훅이 없어도 Context 구조를 사용할 수는 있지만, 훅 없이는 많이 불편합니다. 비즈니스 로직과는 거리가 먼 레벨의 코드들이 반복적으로 추가된다면, 가독성과 유지보수에도 좋지 않을 것입니다.

처음에는 이해하기에 장벽이 존재하기는 합니다만, 컨슈머 훅 까지만 만들어 놓으면, Zustand 부럽지 않은 자유도를 누릴 수 있습니다.

또한, 복합적인 상태관리를 위해서 useReducer.reducer 펑션도 필요한데, 이는 또다른 주제가 되므로 여기서는 다루지 않겠습니다.

여기서 집중해야 할 건, createContext 와 Context.Provider 그리고 Context.Consumer 이렇게 세가지 단계가 있다는 점입니다.

원리와 정책을 분명히 파악했으니 이제 쭉쭉 나가 봅시다.

  1. 전역 State 의 타입과 inititialState 선언

    https://github.com/KangWoosung/zustand-and-usecontext/blob/main/src/app/contexts/types/InitialStateType.ts

// 1. Type for initialState
export type InitialTodoType = {
  todos: TodoType[];
};
export type InitialContextStateType = {
  currentUser: string | null;
  authChecked: boolean;
  todos: TodoType[];
};
export type InitialContextDispatchType = {
  checkAuth: (check: boolean) => void;
  setCurrentUser: (user: string) => void;
  addTodo: (todo: TodoType) => void;
  deleteTodo: (id: string) => void;
  updateTodo: (id: string, updatedFields: Partial<TodoType>) => void;
  loadTodos: () => void;
};

export type InitialStateType = InitialContextStateType &
  InitialContextDispatchType;

// 2. 초기 상태 선언
export const initialState: InitialStateType = {
  currentUser: null,
  authChecked: false,
  todos: [],
  checkAuth: () => {}, // 초기 값은 임의로 설정
  setCurrentUser: () => {},
  addTodo: () => {},
  deleteTodo: () => {},
  updateTodo: () => {},
  loadTodos: () => [],
};
  1. Reducer.Action_Types

https://github.com/KangWoosung/zustand-and-usecontext/blob/main/src/app/contexts/context/action/ionicReducerAction.ts

// reducer 에서 사용되는 Action.Types 선언
const enum REDUCER_ACTION_TYPE {
    SET_USER,
    SET_LOADING,
    SET_ERROR,
    DELETE_TODO,
    UPDATE_TODO,
    ADD_TODO,
    LOAD_TODO,
}
export type ReducerAction = 
| { type: REDUCER_ACTION_TYPE.SET_USER; payload: string }
| { type: REDUCER_ACTION_TYPE.SET_LOADING; payload: boolean }
| { type: REDUCER_ACTION_TYPE.SET_ERROR; payload: boolean }
| { type: REDUCER_ACTION_TYPE.DELETE_TODO; payload: string }
| { type: REDUCER_ACTION_TYPE.UPDATE_TODO; payload: { id: string, updatedFields: Partial<TodoType> } }
| { type: REDUCER_ACTION_TYPE.ADD_TODO; payload: TodoType }
| { type: REDUCER_ACTION_TYPE.LOAD_TODO; payload: TodoType[] }
  1. Reducer function

https://github.com/KangWoosung/zustand-and-usecontext/blob/main/src/app/contexts/context/reducer/ionicReducer.ts

// reducer function
// Action.Type 에 따라 payload 를 처리한다.
export const ionicReducer = (
  state: InitialStateType,
  action: ReducerAction
): InitialStateType => {
  switch (action.type) {
    case REDUCER_ACTION_TYPE.AUTH_CHECKED:
      return { ...state, authChecked: action.check };
    case REDUCER_ACTION_TYPE.SET_CURRENT_USER:
      return { ...state, currentUser: action.user };
    case REDUCER_ACTION_TYPE.ADD_TODO: {
      const updatedTodos = [...state.todos, action.todo];
      saveTodosToLocalStorage(updatedTodos);
      return { ...state, todos: updatedTodos };
    }
    case REDUCER_ACTION_TYPE.DELETE_TODO: {
      const updatedTodos = state.todos.filter((todo) => todo.id !== action.id);
      saveTodosToLocalStorage(updatedTodos);
      return { ...state, todos: updatedTodos };
    }
    case REDUCER_ACTION_TYPE.UPDATE_TODO:
      const updatedTodos = state.todos.map((todo) =>
        todo.id === action.id
          ? { ...todo, ...action.updatedFields, updatedAt: new Date() }
          : todo
      );
      saveTodosToLocalStorage(updatedTodos);
      return { ...state, todos: updatedTodos };
    case REDUCER_ACTION_TYPE.LOAD_TODOS:
      const todos = loadTodosFromLocalStorage();
      return { ...state, todos };
    default:
      return state;
  }
};
  1. useReducer Custom hook function

https://github.com/KangWoosung/zustand-and-usecontext/blob/main/src/app/contexts/context/ver01/hooks/useIonicReducer.tsx Reducer 와 dispatch 의 사용 로직을 커스텀 훅으로 몰아넣습니다. 앱의 코드가 비즈니스 로직에만 집중될 수 있도록 추가하는 커스텀훅 입니다. 이 커스텀 훅이 리턴하는 state 와 펑션들이 전체 context 에서 공유되는 전역 상태 객체가 됩니다.


// 실제로 dispatch 가 적용되는 실행 펑션이 리턴되는 훅 펑션입니다.
// usage:
// const {currentUser, addTodo, deleteTodo} = useIonicReducer()
export const useIonicReducer = () => {
  const [state, dispatch] = useReducer(ionicReducer, initialState);

  return {
    currentUser: state.currentUser,
    authChecked: state.authChecked,
    todos: state.todos,
    checkAuth: (check: boolean) =>
      dispatch({ type: REDUCER_ACTION_TYPE.AUTH_CHECKED, check }),
    setCurrentUser: (user: string) =>
      dispatch({ type: REDUCER_ACTION_TYPE.SET_CURRENT_USER, user }),
    addTodo: (todo: TodoType) =>
      dispatch({ type: REDUCER_ACTION_TYPE.ADD_TODO, todo }),
    deleteTodo: (id: string) =>
      dispatch({ type: REDUCER_ACTION_TYPE.DELETE_TODO, id }),
    updateTodo: (id: string, updatedFields: Partial<TodoType>) =>
      dispatch({ type: REDUCER_ACTION_TYPE.UPDATE_TODO, id, updatedFields }),
    loadTodos: () => dispatch({ type: REDUCER_ACTION_TYPE.LOAD_TODOS }),
  };
};

6.create createContext

https://github.com/KangWoosung/zustand-and-usecontext/blob/main/src/app/contexts/context/ver02/contexts/contexts.ts

// 앱 전체에서 공유되는 Context 의 생성
// Context 를 생성하고 그 인스턴스의 네임을 선언하는 코드입니다.
// 생성된 컨텍스트의 이름 IonicContext 를 앞으로 사용하게 됩니다.
export const IonicContext = createContext<
  ReturnType<typeof useIonicReducer> | undefined
>(undefined);

*. Context 의 최적화 문제

Zustand 에서는 ‘선택적 구독’ ‘Selective subscription’ 을 통해 리랜더링 문제의 최적화가 가능했습니다.

useContext 에서는 Context.Provider 를 중첩으로 엮어줄 수 있습니다.

즉, 상태객체의 종류 또는 위치에 따라 Context 를 분류하고, 해당 분류에 대해 독립적인 Context.Provider 를 할당해서 전역상태들을 분할 하자는 컨셉인거죠.

이번 프로젝트에서는 Context.Provider 를 두 가지 범주로 분류해서 중첩구조로 구성하였습니다.

https://github.com/KangWoosung/zustand-and-usecontext/blob/main/src/app/contexts/context/ver02/useIonicContextVer02.tsx

// 7. Context.Provider
export const IonicContextProvider = ({ children }: { children: ReactNode }) => {
  const [state, dispatch] = useReducer(ionicReducer, initialState);

  return (
    <IonicDispatchContext.Provider value={dispatch}>
      <IonicStateContext.Provider value={state}>
        {children}
      </IonicStateContext.Provider>
    </IonicDispatchContext.Provider>
  );
};

*Context 프로젝트의 시작부터 버전 수정 과정..

/app/context/context/ 폴더에는 두가지 버전의 context 가 존재합니다.

Context 의 최적화와 구조론에 대한 고민에서 두가지 버전이 생긴 것인데,

이해하기에 조금은 어려운 문제가 될 수 있을 것 같아서,

궁금한 분들만 참고하시라고, useIonicContextVer02.tsx 파일 머리에 주석으로 남겨뒀습니다.

글이 업로드 되는 현 시점에서 Demo 는 ver02 버전으로 구동되고 있습니다.

*치명적인 문제 발견과 useIonicReducer 커스텀 훅의 용도 폐기

이 프로젝트가 처음 설계되던 시점에는, 하나의 커스텀 훅 인 Context.Consumer 펑션에서 모든 상태와 액션 펑션들을 받아와서 소비하는 형태로 시작 됐습니다.

그러나 진행 과정에서, 무한 리렌더링 등의 예상치 못한 문제가 발생하였고, 이 문제는, ‘전역상태’ 와 그 업데이트 펑션들이 하나의 import 소스에서 핸들 되었기 때문인 것으로 결론지어졌고, 이 문제가 Context 에서 가장 취약한 문제라는 것도 알게 되었습니다.

이 때문에, useIonicContext 커스텀 훅은 용도폐기 되었고, dispatch 를 최종 소비 펑션 단 까지 전달하면서 마지막 소비 펑션에서 dispatch 액션 펑션을 리턴하는 구조가 만들어지게 되었습니다.

최종적으로 수정된 작업 플로우는,

1. InitialStateType
2. InitialState
3. reducer.Action_Types
4. Reducer.function
5. create CreateContext
6. Context.Provider
7. useContext Hook .... 
// useReducer Custom hook function 이 폐기 되었습니다.

이렇게 변경 되었습니다.

선택적 구독이 가능해진 최종 커스텀 훅 펑션들

https://github.com/KangWoosung/zustand-and-usecontext/blob/main/src/app/contexts/context/ver02/hooks/ionicContextHooksVer02.tsx


import { TodoType } from "@/app/contexts/types/TodoType";
import {
  useIonicDispatchContext,
  useIonicStateContext,
} from "../useIonicContextVer02";
import { REDUCER_ACTION_TYPE } from "../../action/ionicReducerAction";

export const useAddTodo = () => {
  const dispatch = useIonicDispatchContext();
  return (todo: TodoType) =>
    dispatch({ type: REDUCER_ACTION_TYPE.ADD_TODO, todo });
};

export const useDeleteTodo = () => {
  const dispatch = useIonicDispatchContext();
  return (id: string) =>
    dispatch({ type: REDUCER_ACTION_TYPE.DELETE_TODO, id });
};

export const useUpdateTodo = () => {
  const dispatch = useIonicDispatchContext();
  return (id: string, updatedFields: Partial<TodoType>) =>
    dispatch({ type: REDUCER_ACTION_TYPE.UPDATE_TODO, id, updatedFields });
};

export const useLoadTodos = () => {
  const dispatch = useIonicDispatchContext();
  return () => dispatch({ type: REDUCER_ACTION_TYPE.LOAD_TODOS });
};

export const useTodos = () => {
  const state = useIonicStateContext();
  return state.todos;
};

export const useCurrentUser = () => {
  const state = useIonicStateContext();
  return state.currentUser;
};

export const useSetCurrentUser = () => {
  const dispatch = useIonicDispatchContext();
  return (user: string) =>
    dispatch({ type: REDUCER_ACTION_TYPE.SET_CURRENT_USER, user });
};

export const useAuthChecked = () => {
  const state = useIonicStateContext();
  return state.authChecked;
};

최종단 컴포넌트에서 Context 커스텀 훅의 사용

https://github.com/KangWoosung/zustand-and-usecontext/blob/main/src/app/features/context/components/TodoListExportVer02.tsx


// ver02 import
import {
  useTodos,
  useAddTodo,
  useDeleteTodo,
  useUpdateTodo,
  useLoadTodos,
  useCurrentUser,
  useSetCurrentUser,
  useAuthChecked,
} from "@/app/contexts/context/";

const TodoListExport = () => {
  // ver02 Code
  const todos = useTodos();
  const addTodo = useAddTodo();
  const deleteTodo = useDeleteTodo();
  const updateTodo = useUpdateTodo();
  const loadTodos = useLoadTodos();
  const currentUser = useCurrentUser();
  const setCurrentUser = useSetCurrentUser();
  const authChecked = useAuthChecked();

  console.log(currentUser, todos);

  return (
    <TodoList
      currentUser={currentUser}
      setCurrentUser={setCurrentUser}
      loadTodos={loadTodos}
      addTodo={addTodo}
      deleteTodo={deleteTodo}
      updateTodo={updateTodo}
      todos={todos}
    />
  );
};

export default TodoListExport;

이렇게 최종단 에서의 코드는 Zustand 를 사용할 때와 비슷한 수준으로 완성 되었습니다.

요약 정리

// 1. state Context.Provider 와 dispatch Context.Provider 를 분리한다.
export const IonicContextProvider = ({ children }: { children: ReactNode }) => {
  const [state, dispatch] = useReducer(ionicReducer, initialState);

  return (
    <IonicDispatchContext.Provider value={dispatch}>
      <IonicStateContext.Provider value={state}>
        {children}
      </IonicStateContext.Provider>
    </IonicDispatchContext.Provider>
  );
};

// 2. state, dispatch 를 각각 제공하는 커스텀 훅을 추가한다.
export const useIonicStateContext = () => {
  const context = useContext(IonicStateContext);
  if (context === undefined) {
    throw new Error(
      "useIonicStateContext must be used within a IonicContextProvider"
    );
  }
  return context;
};

export const useIonicDispatchContext = () => {
  const context = useContext(IonicDispatchContext);
  if (context === undefined) {
    throw new Error(
      "useIonicDispatchContext must be used within a IonicContextProvider"
    );
  }
  return context;
};

// 3. dispatch 액션을 처리하는 개별 펑션을 리턴하는 커스텀 훅을 추가한다.
export const useLoadTodos = () => {
  const dispatch = useIonicDispatchContext();
  return () => dispatch({ type: REDUCER_ACTION_TYPE.LOAD_TODOS });
};
export const useCurrentUser = () => {
  const state = useIonicStateContext();
  return state.currentUser;
};

*Context 의 전역상태 리셋 문제

Demo 페이지에서…

Context 페이지를 벗어났다가 되돌아왔을 때, Zustand 와는 다르게 로그인 세션? 이 유지되지 않는 반응을 보실 수 있습니다.

useReducer 는 원칙적으로 '복합 상태' 를 관리하는 훅이고, '상태' 는, 해당 컴포넌트가 언마운트/리마운트될 때 초기화 되는 게 정상입니다.

즉, 사용자가 Context.Provider 스코프를 벗어났다가 되돌아올 때, context 가 핸들링하는 전역상태들이 초기화 된다는 문제인데, 이는 React 의 정상적인 작동방식 이라는 것입니다.

반면에 Zustand 는 프로젝트 전역에서 전역 상태가 공유되기 때문에 모킹 Auth 정보가 전역에서 유지되는 것입니다.

이 프로젝트에서 Auth 는 전역상태로 구성한 모킹에 불과하고, Context 예제 페이지가 언마운트/리마운트 되는 과정에서 Zustand 페이지에서 처럼 로그인 정보가 유지되게 하려면, 별도의 세션유지 스토리지나 추가 전역 스테이트 처리가 필요합니다.

그러나 이 문제는 이 프로젝트가 관심을 두는 영역을 벗어나기 때문에, 여기서는 해결되어야 할 문제로 보지 않습니다.

마치면서…

Zustand 에서는 생각보다 어려움이 적었던 것 같습니다. 그만큼 개발자 친화적이고 직관적이라는 뜻도 되겠죠. 본문에서 정리한 Zustand 의 코드와 사용법 정도만 익히면, 대부분의 프로젝트에서는 충분히 커버가 될 듯 합니다.

하지만 Context 의 정리는 조금 아쉬움이 남습니다. Context 의 가능성과 함께 한계도 분명하게 확인했던 프로젝트 였던 것 같긴 하지만, Context.Consumer 까지의 플로우 에 어딘가 조금 더 간소화 될 여지가 있을 것 같긴 한데, 아직 제 눈에는 뚜렷이 보이질 않네요.

나름 깊이 있는 시도를 해본다고 시작했는데, 서둘러 마무리된 느낌도 없진 않고요. 모든 작업 결과들에는 어쨌든 아쉬움이 남긴 하지요.

아무쪼록, 전역 상태관리와 Zustand, Context 에 관련해서 많은 이들에게 도움이 되는 시간이 되었으면 좋겠습니다.

긴 글 따라오시느라 수고하셨습니다.







....