리액트에서 일반적으로 자주 쓰이는 패턴이 뭐다 이렇게 정의할 정도로 아직은 깊이가 없지만,
실무에서 흔히들 많이 쓴다는 패턴을 정리해보려고 한다.
다룰 패턴들은
렌더 프롭스 / 훅스 / 프로토타입 / 프록시 / 싱글톤 / 컴파운드
로 글 하나 당 하나씩 정리해보고자 한다.
이 글은 그 첫번째 글로써
렌더 프롭스에 대해 다루고자 한다.
Render Props 패턴
Render Props는 컴포넌트 간에 코드를 공유하기 위한 테크닉으로, 렌더링할 내용을 props로 전달하는 패턴이다.
컴포넌트의 로직과 UI를 분리하여 재사용성을 높이고, 더 유연한 컴포넌트 설계를 가능하게 해준다.
(사실 패턴이라고 지칭하는게 맞나 싶긴하다..)
1. 기본 구조
// 가장 기본적인 형태
interface RenderProps {
render: (value: string) => React.ReactNode;
}
function SimpleComponent({ render }: RenderProps) {
const value = "Hello World";
return render(value);
}
// 사용 예시
<SimpleComponent render={(value) => <div>{value}</div>} />
2. Children as Function
children을 함수로 사용하는 일반적인 형태
interface ChildrenAsFunction {
children: (value: string) => React.ReactNode;
}
function SimpleComponent({ children }: ChildrenAsFunction) {
const value = "Hello World";
return children(value);
}
// 사용 예시
<SimpleComponent>
{(value) => <div>{value}</div>}
</SimpleComponent>
3. 상태 공유
Render Props는 상태 로직을 여러 컴포넌트에서 재사용할 때 특히 유용하다.
interface ToggleProps {
children: (props: {
isOn: boolean;
toggle: () => void;
}) => React.ReactNode;
}
function Toggle({ children }: ToggleProps) {
const [isOn, setIsOn] = useState(false);
const toggle = () => setIsOn(!isOn);
return children({ isOn, toggle });
}
// 사용 예시
<Toggle>
{({ isOn, toggle }) => (
<button onClick={toggle}>
{isOn ? 'ON' : 'OFF'}
</button>
)}
</Toggle>
4. 데이터 페칭
API 호출과 같은 데이터 페칭 로직 재사용 시에도 유용하다.
interface DataFetcherProps<T> {
url: string;
children: (props: {
data: T | null;
loading: boolean;
error: Error | null;
}) => React.ReactNode;
}
function DataFetcher<T>({ url, children }: DataFetcherProps<T>) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
const response = await fetch(url);
const json = await response.json();
setData(json);
} catch (err) {
setError(err instanceof Error ? err : new Error('Failed to fetch'));
} finally {
setLoading(false);
}
};
fetchData();
}, [url]);
return children({ data, loading, error });
}
// 사용 예시
<DataFetcher url="/api/data">
{({ data, loading, error }) => {
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return <div>{JSON.stringify(data)}</div>;
}}
</DataFetcher>
5. 조건부 렌더링
특정 조건에 따라 다른 컴포넌트를 렌더링할 때도 유용하다.
interface AuthGuardProps {
children: (isAuthenticated: boolean) => React.ReactNode;
}
function AuthGuard({ children }: AuthGuardProps) {
const [isAuthenticated, setIsAuthenticated] = useState(false);
useEffect(() => {
// 인증 상태 체크 로직
const checkAuth = async () => {
const token = localStorage.getItem('token');
setIsAuthenticated(!!token);
};
checkAuth();
}, []);
return children(isAuthenticated);
}
// 사용 예시
<AuthGuard>
{(isAuthenticated) =>
isAuthenticated ? (
<PrivateContent />
) : (
<LoginForm />
)
}
</AuthGuard>
6. 에러 바운더리
Error Boundary는 클래스 컴포넌트로 구현해야 하지만, Render Props 패턴을 활용하여 더 유연하게 사용할 수 있다.
// ErrorBoundary 클래스 컴포넌트
interface ErrorBoundaryProps {
fallback: (error: Error, resetError: () => void) => React.ReactNode;
children: React.ReactNode;
}
interface ErrorBoundaryState {
error: Error | null;
}
class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { error };
}
resetError = () => {
this.setState({ error: null });
};
render() {
const { error } = this.state;
const { fallback, children } = this.props;
if (error) {
return fallback(error, this.resetError);
}
return children;
}
}
예시 1(기본 사용)
function App() {
return (
<ErrorBoundary
fallback={(error, reset) => (
<div>
<h1>Something went wrong!</h1>
<pre>{error.message}</pre>
<button onClick={reset}>Try again</button>
</div>
)}
>
<MyComponent />
</ErrorBoundary>
);
}
예시 2(커스텀 에러 UI 컴포넌트와 함께 사용)
interface ErrorPageProps {
error: Error;
onReset: () => void;
}
function ErrorPage({ error, onReset }: ErrorPageProps) {
return (
<div className="error-container">
<img src="/error-icon.svg" alt="Error" />
<h2>오류가 발생했습니다</h2>
<p>{error.message}</p>
<button onClick={onReset}>다시 시도</button>
</div>
);
}
function App() {
return (
<ErrorBoundary
fallback={(error, reset) => (
<ErrorPage error={error} onReset={reset} />
)}
>
<MyComponent />
</ErrorBoundary>
);
}
예시 3(중첩된 에러 바운더리)
function ComplexApp() {
return (
<ErrorBoundary
fallback={(error, reset) => (
<div className="global-error">
<h1>애플리케이션 오류</h1>
<button onClick={reset}>재시작</button>
</div>
)}
>
<div className="app-container">
<Header />
<ErrorBoundary
fallback={(error, reset) => (
<div className="content-error">
<h2>콘텐츠 로딩 실패</h2>
<button onClick={reset}>다시 시도</button>
</div>
)}
>
<MainContent />
</ErrorBoundary>
<Footer />
</div>
</ErrorBoundary>
);
}
예시 4(조건부 에러처리)
interface ErrorBoundaryProps {
fallback: (error: Error, resetError: () => void) => React.ReactNode;
children: React.ReactNode;
onError?: (error: Error) => void;
shouldCatch?: (error: Error) => boolean;
}
class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
const { onError, shouldCatch } = this.props;
if (shouldCatch?.(error) !== false) {
onError?.(error);
}
}
render() {
const { error } = this.state;
const { fallback, children, shouldCatch } = this.props;
if (error && (!shouldCatch || shouldCatch(error))) {
return fallback(error, this.resetError);
}
return children;
}
}
// 사용 예시
function App() {
return (
<ErrorBoundary
fallback={(error, reset) => <ErrorPage error={error} onReset={reset} />}
onError={(error) => console.error('Caught error:', error)}
shouldCatch={(error) => error.name !== 'ValidationError'}
>
<MyComponent />
</ErrorBoundary>
);
}
Next.js에서 고려해야할 사항
1. 서버 컴포넌트 호환성
컴포넌트가 하는 일에 따라 'use client' 사용 여부가 결정된다.
예시1 (클라이언트 상태나 이벤트를 다루는 경우)
'use client';
interface ToggleProps {
children: (isOn: boolean, toggle: () => void) => React.ReactNode;
}
function Toggle({ children }: ToggleProps) {
const [isOn, setIsOn] = useState(false); // useState 사용으로 'use client' 필요
return children(isOn, () => setIsOn(!isOn));
}
예시2 (브라우저 API를 사용하는 경우)
'use client';
interface LocalStorageProps {
children: (
value: string | null,
setValue: (value: string) => void
) => React.ReactNode;
}
function LocalStorageManager({ children }: LocalStorageProps) {
const [value, setValue] = useState<string | null>(null);
useEffect(() => {
setValue(localStorage.getItem('key'));
}, []);
const updateValue = (newValue: string) => {
localStorage.setItem('key', newValue);
setValue(newValue);
};
return children(value, updateValue);
}
2. 동적 라우팅과의 통합
고려사항
- useRouter, usePathname 등 Next.js의 라우팅 훅 사용시 'use client' 필수
- 서버 컴포넌트와 클라이언트 컴포넌트 간의 상호작용 고려
- 라우팅 전환 시의 상태 관리
- 인증/인가 상태에 따른 라우팅 처리
- 클라이언트 사이드 네비게이션과 서버 사이드 렌더링의 조화
'use client';
interface RouteGuardProps {
children: (routeInfo: {
isLoading: boolean;
isAuthenticated: boolean;
currentPath: string;
navigate: (path: string) => void;
}) => React.ReactNode;
}
function RouteGuard({ children }: RouteGuardProps) {
const router = useRouter();
const pathname = usePathname();
const [isLoading, setIsLoading] = useState(true);
const [isAuthenticated, setIsAuthenticated] = useState(false);
useEffect(() => {
const checkAuth = async () => {
try {
// 인증 체크 로직
const token = localStorage.getItem('token');
setIsAuthenticated(!!token);
// 인증이 필요한 페이지에서 인증되지 않은 경우
if (!token && pathname.startsWith('/protected')) {
router.push('/login');
}
} catch (error) {
console.error('Auth check failed:', error);
} finally {
setIsLoading(false);
}
};
checkAuth();
}, [pathname, router]);
return children({
isLoading,
isAuthenticated,
currentPath: pathname,
navigate: (path) => router.push(path)
});
}
// 사용 예시
function ProtectedLayout({ children }: { children: React.ReactNode }) {
return (
<RouteGuard>
{({ isLoading, isAuthenticated, currentPath, navigate }) => {
if (isLoading) return <LoadingSpinner />;
if (!isAuthenticated) {
// 이미 로그인 페이지로 리다이렉트 중이므로 로딩만 표시
if (currentPath === '/login') return <LoadingSpinner />;
return null;
}
return (
<div>
<nav>
<button onClick={() => navigate('/dashboard')}>Dashboard</button>
<button onClick={() => navigate('/profile')}>Profile</button>
</nav>
{children}
</div>
);
}}
</RouteGuard>
);
}
장점
- 코드 재사용성: 로직을 캡슐화하고 여러 컴포넌트에서 재사용 가능
- 관심사 분리: 데이터 처리 로직과 렌더링 로직을 분리
- 유연성: 렌더링 방식을 사용하는 쪽에서 자유롭게 결정
단점
- 복잡성: 중첩된 Render Props는 코드를 읽기 어렵게 만들 수 있음
- TypeScript: 타입 정의가 복잡해질 수 있음
- 성능: 불필요한 리렌더링이 발생할 수 있음
어느 때에 쓰는게 적합할까
- 조건부 렌더링이 복잡한 경우
- 컴포넌트 트리의 깊은 곳까지 데이터를 전달해야 하는 경우
- 동적인 렌더링 로직이 필요한 경우
- 받은 데이터를 수정할 필요가 없는 컴포넌트의 경우
참고자료
https://patterns-dev-kr.github.io/design-patterns/render-props-pattern/
React를 사용하면 기본적으로 다루게 되는 흔한 패턴이지만,
용례를 적으면서 어느 때에 써야할지 명확해지는 것 같다.
Render Props 패턴은 Hooks의 등장으로 대부분 대체가능한데,
자세한건 Hooks를 다루며 적어보겠다.