리액트에서 일반적으로 자주 쓰이는 패턴이 뭐다 이렇게 정의할 정도로 아직은 깊이가 없지만,
실무에서 흔히들 많이 쓴다는 패턴을 정리해보려고 한다.
다룰 패턴들은
렌더 프롭스 / 훅스 / 프로토타입 / 프록시 / 싱글톤 / 컴파운드
로 글 하나 당 하나씩 정리해보고자 한다.
이 글은 그 첫번째 글로써
렌더 프롭스에 대해 다루고자 한다.
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를 다루며 적어보겠다.
리액트에서 일반적으로 자주 쓰이는 패턴이 뭐다 이렇게 정의할 정도로 아직은 깊이가 없지만,
실무에서 흔히들 많이 쓴다는 패턴을 정리해보려고 한다.
다룰 패턴들은
렌더 프롭스 / 훅스 / 프로토타입 / 프록시 / 싱글톤 / 컴파운드
로 글 하나 당 하나씩 정리해보고자 한다.
이 글은 그 첫번째 글로써
렌더 프롭스에 대해 다루고자 한다.
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를 다루며 적어보겠다.