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, ์ธ์ ๋ญ ์ฐ๋?
useState | useReducer | |
|---|---|---|
| ์ํ ๊ฐ์ | 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๋ฅผ ๋ฐํํ๋ ์ผ์๋ง ์ง์คํ ์ ์๋ค.