Custom Hook: ๋กœ์ง์„ ๋ถ„๋ฆฌํ•˜๊ณ , ์กฐํ•ฉํ•˜๊ธฐ

์ปค์Šคํ…€ ํ›…์€ Container ์ปดํฌ๋„ŒํŠธ๋ฅผ ๋Œ€์ฒดํ•˜๋Š” ๋ฐ์„œ ๊ทธ์น˜์ง€ ์•Š๋Š”๋‹ค.

๋กœ์ง์„ ๋ถ„๋ฆฌํ•˜๊ณ , ์กฐํ•ฉํ•˜๊ณ , ๊ณ„์ธต์„ ๋งŒ๋“œ๋Š” ์„ค๊ณ„ ๋‹จ์œ„๋กœ ํ™œ์šฉํ•  ์ˆ˜ ์žˆ๋‹ค.

์ปค์Šคํ…€ ํ›…: ๋กœ์ง์„ ์ปดํฌ๋„ŒํŠธ ๋ฐ–์œผ๋กœ

์™œ ๊บผ๋‚ด์•ผ ํ•˜๋Š”๊ฐ€

Presentational/Container ํŒจํ„ด์˜ ํ•ต์‹ฌ์€ UI ์ปดํฌ๋„ŒํŠธ๊ฐ€ ๋ฐ์ดํ„ฐ ์ถœ์ฒ˜๋ฅผ ๋ชจ๋ฅด๊ฒŒ ๋งŒ๋“œ๋Š” ๊ฒƒ์ด๋‹ค.

์ปค์Šคํ…€ ํ›…๋„ ๊ฐ™์€ ์›์น™์„ ๋”ฐ๋ฅธ๋‹ค. ์ปดํฌ๋„ŒํŠธ ์•ˆ์— ๋กœ์ง์ด ์„ž์—ฌ ์žˆ์œผ๋ฉด, UI ๋ณ€๊ฒฝ๊ณผ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง ๋ณ€๊ฒฝ์ด ๊ฐ™์€ ํŒŒ์ผ์—์„œ ์ผ์–ด๋‚œ๋‹ค.

๋‹ค์Œ ์ฝ”๋“œ์—์„œ๋Š” actor๊ฐ€ ๋‘˜์ด๋‹ค.

// AS-IS: ๋กœ์ง๊ณผ UI๊ฐ€ ํ•œ ์ปดํฌ๋„ŒํŠธ ์•ˆ์—
function SignupForm() {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [errors, setErrors] = useState<Record<string, string>>({});

  const validate = () => {
    const next: Record<string, string> = {};
    if (!email.includes("@")) next.email = "์˜ฌ๋ฐ”๋ฅธ ์ด๋ฉ”์ผ ํ˜•์‹์ด ์•„๋‹™๋‹ˆ๋‹ค.";
    if (password.length < 8) next.password = "๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” 8์ž ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.";
    return next;
  };

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    const errs = validate();
    if (Object.keys(errs).length > 0) return setErrors(errs);
    // ์ œ์ถœ ๋กœ์ง...
  };

  return (
    <form onSubmit={handleSubmit}>
      <input value={email} onChange={(e) => setEmail(e.target.value)} />
      {errors.email && <p>{errors.email}</p>}
      <input value={password} onChange={(e) => setPassword(e.target.value)} />
      {errors.password && <p>{errors.password}</p>}
      <button type="submit">๊ฐ€์ž…ํ•˜๊ธฐ</button>
    </form>
  );
}

์œ ํšจ์„ฑ ๊ฒ€์‚ฌ ๊ทœ์น™์ด ๋ฐ”๋€Œ๋ฉด UI ํŒŒ์ผ์„ ์—ด์–ด์•ผ ํ•œ๋‹ค. ๊ฐ™์€ ํผ ๋กœ์ง์„ ๋‹ค๋ฅธ ์ปดํฌ๋„ŒํŠธ์—์„œ ์“ฐ๊ณ  ์‹ถ์œผ๋ฉด ๋ณต์‚ฌ-๋ถ™์—ฌ๋„ฃ๊ธฐ๋ฅผ ํ•ด์•ผํ•œ๋‹ค.

์ด๋•Œ ์ปค์Šคํ…€ ํ›…์œผ๋กœ ๋ถ„๋ฆฌํ•˜๋ฉด ๋กœ์ง ์žฌ์‚ฌ์šฉ๊ณผ UI ๋…๋ฆฝ์ด ๋™์‹œ์— ๊ฐ€๋Šฅํ•ด์ง„๋‹ค.

// TO-BE: ๋กœ์ง์„ ํ›…์œผ๋กœ ๋ถ„๋ฆฌ
function useForm<T extends Record<string, string>>(initialValues: T) {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState<Partial<T>>({});

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const { name, value } = e.target;
    setValues((prev) => ({ ...prev, [name]: value }));
  };

  const setFieldError = (field: keyof T, message: string) => {
    setErrors((prev) => ({ ...prev, [field]: message }));
  };

  const reset = () => {
    setValues(initialValues);
    setErrors({});
  };

  return { values, errors, handleChange, setFieldError, reset };
}
// UI ์ปดํฌ๋„ŒํŠธ๋Š” ํ›…์ด ์–ด๋–ป๊ฒŒ ๋™์ž‘ํ•˜๋Š”์ง€ ๋ชจ๋ฅธ๋‹ค
function SignupForm() {
  const { values, errors, handleChange, setFieldError } = useForm({
    email: "",
    password: "",
  });

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (!values.email.includes("@")) return setFieldError("email", "์˜ฌ๋ฐ”๋ฅธ ์ด๋ฉ”์ผ ํ˜•์‹์ด ์•„๋‹™๋‹ˆ๋‹ค.");
    if (values.password.length < 8) return setFieldError("password", "๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” 8์ž ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.");
    // ์ œ์ถœ ๋กœ์ง...
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="email" value={values.email} onChange={handleChange} />
      {errors.email && <p>{errors.email}</p>}
      <input name="password" value={values.password} onChange={handleChange} />
      {errors.password && <p>{errors.password}</p>}
      <button type="submit">๊ฐ€์ž…ํ•˜๊ธฐ</button>
    </form>
  );
}

useForm์€ ์–ด๋–ค ํผ์—์„œ๋„ ์žฌ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๊ณ , SignupForm์€ UI๋งŒ์„ ์ฑ…์ž„์ง„๋‹ค.


useReducer + Context: ๋ณต์žกํ•œ ์ƒํƒœ ๊ตฌ์กฐํ™”

useState์˜ ํ•œ๊ณ„

์•„๋ž˜ TodoList๋Š” ์š”๊ตฌ์‚ฌํ•ญ์ด ๋Š˜์–ด๋‚ ์ˆ˜๋ก ๋ฌธ์ œ๊ฐ€ ์ƒ๊ธด๋‹ค.

function TodoList({ children }) {
  const [todos, setTodos] = useState<Todo[]>([]);
  const [input, setInput] = useState("");

  const addTodo = () => {
    if (!input.trim()) return;
    setTodos((prev) => [...prev, { id: Date.now(), text: input, completed: false }]);
    setInput("");
  };

  const toggleTodo = (id: number) => {
    setTodos((prev) =>
      prev.map((t) => (t.id === id ? { ...t, completed: !t.completed } : t))
    );
  };

  const deleteTodo = (id: number) => {
    setTodos((prev) => prev.filter((t) => t.id !== id));
  };

  return (
    <TodoContext.Provider value={{ todos, input, setInput, addTodo, toggleTodo, deleteTodo }}>
      <div>{children}</div>
    </TodoContext.Provider>
  );
}

ํ•„ํ„ฐ, ์šฐ์„ ์ˆœ์œ„, ์ผ๊ด„ ์ฒ˜๋ฆฌ๊ฐ€ ์ถ”๊ฐ€๋˜๋ฉด useState + ํ•ธ๋“ค๋Ÿฌ ํ•จ์ˆ˜๊ฐ€ ๊ณ„์† ๋Š˜์–ด๋‚œ๋‹ค. ์ƒํƒœ ๋ณ€๊ฒฝ ๋กœ์ง์ด ์ปดํฌ๋„ŒํŠธ ์ „์ฒด์— ํฉ์–ด์ง„๋‹ค.

useReducer: action์„ ํ†ตํ•œ ์ƒํƒœ ์ „ํ™˜

useReducer๋Š” (state, action) => newState ํ˜•ํƒœ์˜ ์ˆœ์ˆ˜ ํ•จ์ˆ˜(reducer)๋กœ ์ƒํƒœ ์ „ํ™˜์„ ๋ช…์‹œ์ ์œผ๋กœ ํ‘œํ˜„ํ•œ๋‹ค.

type Todo = {
  id: number;
  text: string;
  completed: boolean;
};

type TodoAction =
  | { type: "ADD"; text: string }
  | { type: "TOGGLE"; id: number }
  | { type: "DELETE"; id: number }
  | { type: "CLEAR_COMPLETED" };

function todoReducer(state: Todo[], action: TodoAction): Todo[] {
  switch (action.type) {
    case "ADD":
      return [...state, { id: Date.now(), text: action.text, completed: false }];
    case "TOGGLE":
      return state.map((t) => (t.id === action.id ? { ...t, completed: !t.completed } : t));
    case "DELETE":
      return state.filter((t) => t.id !== action.id);
    case "CLEAR_COMPLETED":
      return state.filter((t) => !t.completed);
    default:
      return state;
  }
}

reducer๋Š” ์ปดํฌ๋„ŒํŠธ ๋ฐ–์— ์ •์˜ํ•˜๋Š” ์ˆœ์ˆ˜ ํ•จ์ˆ˜๋‹ค. ๊ฐœ๋ฐœ์ž๋Š” ์ปดํฌ๋„ŒํŠธ์™€ ๋…๋ฆฝ์ ์œผ๋กœ ํ…Œ์ŠคํŠธํ•  ์ˆ˜ ์žˆ๊ณ , ๋ชจ๋“  ์ƒํƒœ ์ „ํ™˜ ๋กœ์ง์„ ํ•œ ๊ณณ์—์„œ ๊ด€๋ฆฌํ•œ๋‹ค.

const [todos, dispatch] = useReducer(todoReducer, []);

// ์‚ฌ์šฉ
dispatch({ type: "ADD", text: "๋ฆฌ์•กํŠธ ๊ณต๋ถ€ํ•˜๊ธฐ" });
dispatch({ type: "TOGGLE", id: 1 });
dispatch({ type: "CLEAR_COMPLETED" });

Context์™€ ๊ฒฐํ•ฉ

dispatch๋ฅผ Context๋กœ ๋‚ด๋ ค๋ณด๋‚ด๋ฉด, ์„œ๋ธŒ ์ปดํฌ๋„ŒํŠธ๋“ค์ด props ์—†์ด ์ƒํƒœ๋ฅผ ๋ณ€๊ฒฝํ•  ์ˆ˜ ์žˆ๋‹ค.

type TodoContextValue = {
  todos: Todo[];
  dispatch: React.Dispatch<TodoAction>;
};

const TodoContext = createContext<TodoContextValue | null>(null);

function useTodoContext() {
  const ctx = useContext(TodoContext);
  if (!ctx) throw new Error("TodoList ์•ˆ์—์„œ๋งŒ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.");
  return ctx;
}

function TodoList({ children }: { children: React.ReactNode }) {
  const [todos, dispatch] = useReducer(todoReducer, []);

  return (
    <TodoContext.Provider value={{ todos, dispatch }}>
      <div>{children}</div>
    </TodoContext.Provider>
  );
}
// ์„œ๋ธŒ ์ปดํฌ๋„ŒํŠธ๋Š” dispatch๋กœ ์ƒํƒœ๋ฅผ ๋ณ€๊ฒฝ
TodoList.AddForm = function AddForm() {
  const { dispatch } = useTodoContext();
  const [input, setInput] = useState("");

  const handleAdd = () => {
    if (!input.trim()) return;
    dispatch({ type: "ADD", text: input });
    setInput("");
  };

  return (
    <div>
      <input value={input} onChange={(e) => setInput(e.target.value)} />
      <button onClick={handleAdd}>์ถ”๊ฐ€</button>
    </div>
  );
};

TodoList.Items = function Items() {
  const { todos, dispatch } = useTodoContext();

  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id}>
          <span
            style={{ textDecoration: todo.completed ? "line-through" : "none" }}
            onClick={() => dispatch({ type: "TOGGLE", id: todo.id })}
          >
            {todo.text}
          </span>
          <button onClick={() => dispatch({ type: "DELETE", id: todo.id })}>์‚ญ์ œ</button>
        </li>
      ))}
    </ul>
  );
};

useState vs useReducer, ์–ธ์ œ ๋ญ˜ ์“ฐ๋‚˜?

useStateuseReducer
์ƒํƒœ ๊ฐœ์ˆ˜1~2๊ฐœ์—ฌ๋Ÿฌ ๊ฐœ๊ฐ€ ์—ฐ๊ด€๋  ๋•Œ
์ „ํ™˜ ๋ณต์žก๋„๋‹จ์ˆœ์กฐ๊ฑด ๋ถ„๊ธฐ๊ฐ€ ๋งŽ์„ ๋•Œ
ํ…Œ์ŠคํŠธ์ปดํฌ๋„ŒํŠธ ๋‹จ์œ„reducer ๋‹จ์œ„๋กœ ๊ฒฉ๋ฆฌ ๊ฐ€๋Šฅ
๊ฐ€๋…์„ฑ์ง๊ด€์ action ์ด๋ฆ„์œผ๋กœ ์˜๋„ ๋ช…์‹œ

๋‹จ์ˆœํ•œ ํ† ๊ธ€, ์ž…๋ ฅ ์ƒํƒœ๋ผ๋ฉด useState๋กœ ์ถฉ๋ถ„ํ•˜๋‹ค. ์ƒํƒœ ์ „ํ™˜ ๊ฒฝ์šฐ์˜ ์ˆ˜๊ฐ€ ๋งŽ์•„์ง€๊ฑฐ๋‚˜, ์—ฌ๋Ÿฌ ์ƒํƒœ๊ฐ€ ํ•จ๊ป˜ ๋ณ€ํ•ด์•ผ ํ•œ๋‹ค๋ฉด useReducer๋ฅผ ๊ณ ๋ คํ•œ๋‹ค.


ํ›… ํ•ฉ์„ฑ(Composition) ์ „๋žต

์ปค์Šคํ…€ ํ›…์€ ๋‹ค๋ฅธ ํ›…์„ ๋‚ด๋ถ€์—์„œ ํ˜ธ์ถœํ•  ์ˆ˜ ์žˆ๋‹ค. ๋˜ํ•œ ์ž‘์€ ํ›…์„ ์กฐํ•ฉํ•ด ๋” ํฐ ํ›…์„ ๋งŒ๋“ ๋‹ค.

๋ ˆ์ด์–ด ์„ค๊ณ„

useTodoApp          โ† ์ตœ์ƒ์œ„: ๋ชจ๋“  ๊ฒƒ์„ ์กฐํ•ฉ
  โ”œโ”€โ”€ useTodoReducer    โ† ๋„๋ฉ”์ธ ์ƒํƒœ
  โ”œโ”€โ”€ useLocalStorage   โ† ์ธํ”„๋ผ: ์˜์†์„ฑ
  โ””โ”€โ”€ useFilter         โ† UI ๊ด€์‹ฌ์‚ฌ: ํ•„ํ„ฐ๋ง

์ด๋•Œ ๊ฐ ํ›…์€ ํ•˜๋‚˜์˜ ๊ด€์‹ฌ์‚ฌ๋งŒ ๊ฐ€์ง„๋‹ค.

// 1. ์ธํ”„๋ผ ํ›…: ๋กœ์ปฌ ์Šคํ† ๋ฆฌ์ง€ ๋™๊ธฐํ™”
function useLocalStorage<T>(key: string, initialValue: T) {
  const [value, setValue] = useState<T>(() => {
    try {
      const stored = localStorage.getItem(key);
      return stored ? JSON.parse(stored) : initialValue;
    } catch {
      return initialValue;
    }
  });

  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);

  return [value, setValue] as const;
}
// 2. ๋„๋ฉ”์ธ ํ›…: Todo ์ƒํƒœ ๊ด€๋ฆฌ
function useTodoReducer(initialTodos: Todo[] = []) {
  const [todos, dispatch] = useReducer(todoReducer, initialTodos);

  const add = (text: string) => dispatch({ type: "ADD", text });
  const toggle = (id: number) => dispatch({ type: "TOGGLE", id });
  const remove = (id: number) => dispatch({ type: "DELETE", id });
  const clearCompleted = () => dispatch({ type: "CLEAR_COMPLETED" });

  return { todos, add, toggle, remove, clearCompleted };
}
// 3. UI ๊ด€์‹ฌ์‚ฌ ํ›…: ํ•„ํ„ฐ๋ง
type Filter = "all" | "active" | "completed";

function useFilter(todos: Todo[]) {
  const [filter, setFilter] = useState<Filter>("all");

  const filtered = todos.filter((t) => {
    if (filter === "active") return !t.completed;
    if (filter === "completed") return t.completed;
    return true;
  });

  return { filter, setFilter, filtered };
}
// 4. ํ•ฉ์„ฑ ํ›…: ๋ชจ๋“  ๊ด€์‹ฌ์‚ฌ๋ฅผ ์กฐํ•ฉ
function useTodoApp() {
  const [storedTodos, setStoredTodos] = useLocalStorage<Todo[]>("todos", []);
  const { todos, add, toggle, remove, clearCompleted } = useTodoReducer(storedTodos);
  const { filter, setFilter, filtered } = useFilter(todos);

  // todos๊ฐ€ ๋ฐ”๋€Œ๋ฉด ๋กœ์ปฌ ์Šคํ† ๋ฆฌ์ง€์— ๋™๊ธฐํ™”
  useEffect(() => {
    setStoredTodos(todos);
  }, [todos]);

  return { filtered, filter, setFilter, add, toggle, remove, clearCompleted };
}
// ์ปดํฌ๋„ŒํŠธ๋Š” useTodoApp๋งŒ ์•Œ๋ฉด ๋œ๋‹ค
function TodoApp() {
  const { filtered, filter, setFilter, add, toggle, remove } = useTodoApp();

  return (
    <div>
      <FilterBar filter={filter} onChange={setFilter} />
      <TodoList todos={filtered} onToggle={toggle} onDelete={remove} />
      <AddForm onAdd={add} />
    </div>
  );
}

ํ•ฉ์„ฑํ•  ๋•Œ ์ฃผ์˜ํ•  ์ 

์˜์กด ๋ฐฉํ–ฅ์„ ๋‹จ๋ฐฉํ–ฅ์œผ๋กœ ์œ ์ง€ํ•œ๋‹ค.

useTodoApp โ†’ useTodoReducer  (O)
useTodoApp โ†’ useLocalStorage (O)
useTodoReducer โ†’ useLocalStorage (X)  // ์ธํ”„๋ผ ํ›…์ด ๋„๋ฉ”์ธ ํ›…์„ ๋ชฐ๋ผ์•ผ ํ•œ๋‹ค

useLocalStorage๋Š” Todo๋ฅผ ๋ชจ๋ฅธ๋‹ค. ์–ด๋–ค ํƒ€์ž…์ด๋“  ์ €์žฅํ•  ์ˆ˜ ์žˆ๋Š” ๋ฒ”์šฉ ํ›…์ด๋‹ค. ๋„๋ฉ”์ธ๊ณผ ์ธํ”„๋ผ๋ฅผ ์„ž์œผ๋ฉด ์žฌ์‚ฌ์šฉ์„ฑ์ด ๋ฌด๋„ˆ์ง„๋‹ค.

ํ•ฉ์„ฑ ๊นŠ์ด๋Š” 2~3๋‹จ๊ณ„๊ฐ€ ์ ๋‹นํ•˜๋‹ค.

ํ›…์ด ํ›…์„ ๋ถ€๋ฅด๊ณ , ๊ทธ ํ›…์ด ๋˜ ํ›…์„ ๋ถ€๋ฅด๋ฉด ๋””๋ฒ„๊น…์ด ์–ด๋ ค์›Œ์ง„๋‹ค. ์–ด๋А ๋‹จ๊ณ„์—์„œ ์ƒํƒœ๊ฐ€ ๋ฐ”๋€Œ๋Š”์ง€ ์ถ”์ ํ•˜๊ธฐ ์–ด๋ ค์›Œ์ง€๊ธฐ ๋•Œ๋ฌธ์ด๋‹ค. ๋ ˆ์ด์–ด๊ฐ€ ๊นŠ์–ด์ง„๋‹ค๋ฉด ์„ค๊ณ„๋ฅผ ๋‹ค์‹œ ๋“ค์—ฌ๋‹ค๋ณผ ์‹ ํ˜ธ๋‹ค.


์ •๋ฆฌ

์ปค์Šคํ…€ ํ›…์„ ํ†ตํ•ด ๊ด€์‹ฌ์‚ฌ๋ฅผ ๋‚˜๋ˆ„๊ณ , ์กฐํ•ฉํ•˜๊ณ , ๊ณ„์ธต์„ ๊ตฌ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค.

  • ์ปค์Šคํ…€ ํ›…: UI์—์„œ ๋กœ์ง์„ ๋ถ„๋ฆฌ. Presentational ์ปดํฌ๋„ŒํŠธ๊ฐ€ UI๋งŒ ์ฑ…์ž„์ง€๊ฒŒ ๋งŒ๋“ ๋‹ค.
  • useReducer + Context: ๋ณต์žกํ•œ ์ƒํƒœ ์ „ํ™˜์„ action์œผ๋กœ ๋ช…์‹œํ™”ํ•˜๊ณ , ์„œ๋ธŒ ์ปดํฌ๋„ŒํŠธ์— dispatch๋ฅผ ์ „๋‹ฌํ•œ๋‹ค.
  • ํ›… ํ•ฉ์„ฑ: ์ž‘์€ ํ›…์„ ์กฐํ•ฉํ•ด ํฐ ํ›…์„ ๋งŒ๋“ ๋‹ค. ๊ฐ ํ›…์€ ํ•˜๋‚˜์˜ ๊ด€์‹ฌ์‚ฌ๋งŒ ๊ฐ–๋Š”๋‹ค.

์„ธ ๊ฐ€์ง€๋ฅผ ํ•จ๊ป˜ ์“ฐ๋ฉด, ์ปดํฌ๋„ŒํŠธ๋Š” ์˜ค์ง JSX๋ฅผ ๋ฐ˜ํ™˜ํ•˜๋Š” ์ผ์—๋งŒ ์ง‘์ค‘ํ•  ์ˆ˜ ์žˆ๋‹ค.

๋Œ“๊ธ€์„ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...