Medium에서 이 아티클의 제목(Why You Shouldn’t Pass React’s setState as a Prop: A Deep Dive)을 보고 순간 뜨끔해져 찬찬히 읽어보지 않을 수 없었다.
현재 진행하고 있는 프로젝트에서 setState을 자식 컴포넌트에 prop으로 넘겨주고 있었는데, 코드를 작성하면서도 "이렇게 넘겨주면 안될 것 같은데..." 라는 마음의 소리를 뒤로하고 일단 잘 작동하니까 놔두고 있었기 때문이었다..
아티클을 읽고 이러한 코드 작성 방식이 구체적으로 어떤 문제를 일으킬 수 있는지 이해하게 되었고,이를 바탕으로 현재의 내 코드를 개선시켜보았다.
캡슐화는 React와 컴포넌트 기반 구조에서 중요한 원칙이다. 각 컴포넌트는 캡슐화의 원칙에 따라 자신에게 속한 로직과 상태를 독립적으로 관리함으로써, 컴포넌트 간 의존도를 낮추고 테스트와 재사용성을 높일 수 있게 된다.
setState를 자식 컴포넌트에 prop로 전달하게 되면, 상태값이 변할 때 어디서 어떻게 상태값을 변화시키고 있는지 디버깅하기가 복잡해진다. 뿐만 아니라 리팩토링도 어려워지는데, 부모 컴포넌트에서 상태값의 구조를 변경한다면 자식 컴포넌트에서도 이에 맞게 수정이 필요하게 된다.
setState를 잘못 사용하면 불필요한 리렌더를 발생시키게 되고, 이는 성능을 저하시키는 요인이 될 수 있다. 자식 컴포넌트가 부모 컴포넌트의 상태값을 자유롭게 변경할 수 있다면, 부모 컴포넌트에서 리렌더가 발생하게 되고, 이는 전체 하위 트리의 리렌더링으로 이어지게 된다.
첫번째는 가장 단순한 방법이다. 상태값을 필요로 하는 컴포넌트 중 가장 상위 컴포넌트에서 상태값을 관리하고, setState을 호출하는 함수를 prop으로 자식 컴포넌트에 전달하는 것이다.
prop drilling 문제가 발생할 수 있다.const ParentComponent = () => {
const [count, setCount] = useState(0);
const incrementCount = () => setCount(prevCount => prevCount + 1);
return (
<div>
<ChildComponent onIncrement={incrementCount} />
<p>Count: {count}</p>
</div>
);
};
const ChildComponent = ({ onIncrement }) => {
return <button onClick={onIncrement}>Increment</button>;
};
상태값을 더 전역적으로 관리하기 위해서는 Context API를 사용하면 효율적으로 상태값 접근 및 관리가 가능해진다.
prop drilling 문제를 해결할 수 있고, 상태를 전역적으로 관리할 수 있다.const CountContext = React.createContext();
function ParentComponent() {
const [count, setCount] = useState(0);
const incrementCount = () => setCount(count + 1);
return (
<CountContext.Provider value={{ count, incrementCount }}> // Provide the value to the context
<ChildComponent />
</CountContext.Provider> context
);
}
function ChildComponent() {
const { incrementCount } = useContext(CountContext);
return <button onClick={incrementCount}>Increment</button>;
}
Custom Hook을 사용하면 컴포넌트 간 로직과 상태관리 함수를 재사용할 수 있다. 상태과 관련 로직을 모두 캡슐화하여 컴포넌트 간에 로직을 모듈식으로 공유함으로써, 가독성과 코드의 간결성을 높일 수 있다.
Custom Hooks를 사용하는 방법에 익숙하지 않으면 초기 학습 곡선이 있을 수 있다.const useCounter = (initialValue = 0) => {
const [count, setCount] = useState(initialValue);
const incrementCount = () => setCount(count + 1);
return { count, incrementCount };
};
const ParentComponents = () => {
const { count, incrementCount } = useCounter();
return (
<div>
<ChildComponent onIncrement={incrementCount} />
<p>Count: {count}</p>
</div>
);
};
const ChildComponents = ({ onIncrement }) => {
return <button onClick={onIncrement}>Increment</button>;
};
기존 코드는 아래와 같다. 게임을 플레이하는 Play 컴포넌트 안에서 현재 남은 생명의 개수인 lives와 게임 상태(진행중/통과/실패)를 나타내는 gameStatus를 관리하고 있고, 각각의 setState 함수를 하위 컴포넌트인 Grid 컴포넌트에게 prop으로 전달하고 있다.
// src/components/Play/Play.tsx
export default function Play() {
...
const [lives, setLives] = useState<number>(initialLives);
const [gameStatus, setGameStatus] = useState<"won" | "lost" | "playing">(
"playing"
);
...
return (
...
<div className={styles.gridWrapper}>
<Grid
...
setLives={setLives}
setGameStatus={setGameStatus}
/>
</div>
...
);
}
lives와 gameStatus 전반적인 게임 진행 상황을 관리하는 상태이기 때문에, 이후 서비스 확장성을 생각한다면 여러 컴포넌트에 사용될 가능성이 높다. 이 때 발생할 수 있는 prop drilling 문제를 방지하고 코드의 확장성을 높이기 위해 Context API를 사용하여 상태와 상태 변경 함수를 중앙에서 관리하는 방법으로 개선해보자.
전역적으로 상태를 관리할Context와, 이 Context를 전달할 Context Provider를 생성한다.
// src/context/GameContext.tsx
import { initialLives } from "@/utils/constants";
import React, { createContext } from "react";
interface GameContextProps {
lives: number;
setLives: (lives: number) => void;
gameStatus: "won" | "lost" | "playing";
setGameStatus: (status: "won" | "lost" | "playing") => void;
}
const GameContext = createContext<GameContextProps | undefined>(undefined);
export const GameProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [lives, setLives] = React.useState<number>(initialLives);
const [gameStatus, setGameStatus] = React.useState<
"won" | "lost" | "playing"
>("playing");
return (
<GameContext.Provider
value={{ lives, setLives, gameStatus, setGameStatus }}
>
{children}
</GameContext.Provider>
);
};
export const useGameContext = () => {
const context = React.useContext(GameContext);
if (!context) {
throw new Error("useGameContext must be used within a GameProvider");
}
return context;
};
// src/components/Play/Play.tsx
import { GameProvider } from "@/context/GameContext";
export default function Play() {
...
return (
<GameProvider>
...
<div className={styles.gridWrapper}>
<Grid
level={level}
mode={mode}
lifeRefs={lifeRefs}
newGameKey={newGameKey}
/>
</div>
...
</GameProvider>
);
}
Grid 컴포넌트에서 useGameContext 훅을 사용하여 setLives와 setGameStatus를 가져온다.
// src/components/Grid/Grid.tsx
import { useGameContext } from "@/context/GameContext";
const Grid: React.FC<GridProps> = (...) => {
const { lives, setLives, setGameStatus } = useGameContext();
...
return (
...
)
};
export default Grid;