Render props enable flexible component composition through function children. Here's how to use them effectively.
Basic Render Props#
1// Basic render prop component
2interface MousePosition {
3 x: number;
4 y: number;
5}
6
7interface MouseTrackerProps {
8 render: (position: MousePosition) => React.ReactNode;
9}
10
11function MouseTracker({ render }: MouseTrackerProps) {
12 const [position, setPosition] = useState<MousePosition>({ x: 0, y: 0 });
13
14 useEffect(() => {
15 const handleMouseMove = (e: MouseEvent) => {
16 setPosition({ x: e.clientX, y: e.clientY });
17 };
18
19 window.addEventListener('mousemove', handleMouseMove);
20 return () => window.removeEventListener('mousemove', handleMouseMove);
21 }, []);
22
23 return <>{render(position)}</>;
24}
25
26// Usage
27function App() {
28 return (
29 <MouseTracker
30 render={({ x, y }) => (
31 <div>
32 Mouse position: {x}, {y}
33 </div>
34 )}
35 />
36 );
37}Children as Function#
1// Using children as render prop
2interface MouseTrackerProps {
3 children: (position: MousePosition) => React.ReactNode;
4}
5
6function MouseTracker({ children }: MouseTrackerProps) {
7 const [position, setPosition] = useState<MousePosition>({ x: 0, y: 0 });
8
9 useEffect(() => {
10 const handleMouseMove = (e: MouseEvent) => {
11 setPosition({ x: e.clientX, y: e.clientY });
12 };
13
14 window.addEventListener('mousemove', handleMouseMove);
15 return () => window.removeEventListener('mousemove', handleMouseMove);
16 }, []);
17
18 return <>{children(position)}</>;
19}
20
21// Usage - cleaner syntax
22function App() {
23 return (
24 <MouseTracker>
25 {({ x, y }) => (
26 <div>
27 Mouse position: {x}, {y}
28 </div>
29 )}
30 </MouseTracker>
31 );
32}Data Fetching#
1interface FetchProps<T> {
2 url: string;
3 children: (state: {
4 data: T | null;
5 loading: boolean;
6 error: Error | null;
7 refetch: () => void;
8 }) => React.ReactNode;
9}
10
11function Fetch<T>({ url, children }: FetchProps<T>) {
12 const [data, setData] = useState<T | null>(null);
13 const [loading, setLoading] = useState(true);
14 const [error, setError] = useState<Error | null>(null);
15
16 const fetchData = useCallback(async () => {
17 setLoading(true);
18 setError(null);
19
20 try {
21 const response = await fetch(url);
22 if (!response.ok) throw new Error('Request failed');
23 const json = await response.json();
24 setData(json);
25 } catch (err) {
26 setError(err as Error);
27 } finally {
28 setLoading(false);
29 }
30 }, [url]);
31
32 useEffect(() => {
33 fetchData();
34 }, [fetchData]);
35
36 return <>{children({ data, loading, error, refetch: fetchData })}</>;
37}
38
39// Usage
40function UserProfile({ userId }: { userId: string }) {
41 return (
42 <Fetch<User> url={`/api/users/${userId}`}>
43 {({ data, loading, error, refetch }) => {
44 if (loading) return <Spinner />;
45 if (error) return <ErrorMessage error={error} onRetry={refetch} />;
46 if (!data) return null;
47
48 return (
49 <div>
50 <h1>{data.name}</h1>
51 <p>{data.email}</p>
52 </div>
53 );
54 }}
55 </Fetch>
56 );
57}Toggle Pattern#
1interface ToggleProps {
2 initialValue?: boolean;
3 children: (state: {
4 on: boolean;
5 toggle: () => void;
6 setOn: () => void;
7 setOff: () => void;
8 }) => React.ReactNode;
9}
10
11function Toggle({ initialValue = false, children }: ToggleProps) {
12 const [on, setOn] = useState(initialValue);
13
14 const toggle = useCallback(() => setOn((prev) => !prev), []);
15 const setOnTrue = useCallback(() => setOn(true), []);
16 const setOnFalse = useCallback(() => setOn(false), []);
17
18 return (
19 <>
20 {children({
21 on,
22 toggle,
23 setOn: setOnTrue,
24 setOff: setOnFalse,
25 })}
26 </>
27 );
28}
29
30// Usage
31function App() {
32 return (
33 <Toggle>
34 {({ on, toggle }) => (
35 <div>
36 <button onClick={toggle}>
37 {on ? 'Hide' : 'Show'} Content
38 </button>
39 {on && <div>This is the content!</div>}
40 </div>
41 )}
42 </Toggle>
43 );
44}Form Handling#
1interface FormState<T> {
2 values: T;
3 errors: Partial<Record<keyof T, string>>;
4 touched: Partial<Record<keyof T, boolean>>;
5}
6
7interface FormProps<T extends Record<string, any>> {
8 initialValues: T;
9 validate?: (values: T) => Partial<Record<keyof T, string>>;
10 onSubmit: (values: T) => void | Promise<void>;
11 children: (state: {
12 values: T;
13 errors: Partial<Record<keyof T, string>>;
14 touched: Partial<Record<keyof T, boolean>>;
15 isSubmitting: boolean;
16 handleChange: (name: keyof T) => (e: React.ChangeEvent<HTMLInputElement>) => void;
17 handleBlur: (name: keyof T) => () => void;
18 handleSubmit: (e: React.FormEvent) => void;
19 }) => React.ReactNode;
20}
21
22function Form<T extends Record<string, any>>({
23 initialValues,
24 validate,
25 onSubmit,
26 children,
27}: FormProps<T>) {
28 const [values, setValues] = useState<T>(initialValues);
29 const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({});
30 const [touched, setTouched] = useState<Partial<Record<keyof T, boolean>>>({});
31 const [isSubmitting, setIsSubmitting] = useState(false);
32
33 const handleChange = (name: keyof T) => (
34 e: React.ChangeEvent<HTMLInputElement>
35 ) => {
36 const value = e.target.value;
37 setValues((prev) => ({ ...prev, [name]: value }));
38 };
39
40 const handleBlur = (name: keyof T) => () => {
41 setTouched((prev) => ({ ...prev, [name]: true }));
42 if (validate) {
43 setErrors(validate(values));
44 }
45 };
46
47 const handleSubmit = async (e: React.FormEvent) => {
48 e.preventDefault();
49
50 if (validate) {
51 const validationErrors = validate(values);
52 setErrors(validationErrors);
53 if (Object.keys(validationErrors).length > 0) return;
54 }
55
56 setIsSubmitting(true);
57 try {
58 await onSubmit(values);
59 } finally {
60 setIsSubmitting(false);
61 }
62 };
63
64 return (
65 <>
66 {children({
67 values,
68 errors,
69 touched,
70 isSubmitting,
71 handleChange,
72 handleBlur,
73 handleSubmit,
74 })}
75 </>
76 );
77}
78
79// Usage
80function LoginForm() {
81 return (
82 <Form
83 initialValues={{ email: '', password: '' }}
84 validate={(values) => {
85 const errors: Record<string, string> = {};
86 if (!values.email) errors.email = 'Required';
87 if (!values.password) errors.password = 'Required';
88 return errors;
89 }}
90 onSubmit={async (values) => {
91 await login(values);
92 }}
93 >
94 {({ values, errors, touched, isSubmitting, handleChange, handleBlur, handleSubmit }) => (
95 <form onSubmit={handleSubmit}>
96 <input
97 value={values.email}
98 onChange={handleChange('email')}
99 onBlur={handleBlur('email')}
100 />
101 {touched.email && errors.email && <span>{errors.email}</span>}
102
103 <input
104 type="password"
105 value={values.password}
106 onChange={handleChange('password')}
107 onBlur={handleBlur('password')}
108 />
109 {touched.password && errors.password && <span>{errors.password}</span>}
110
111 <button type="submit" disabled={isSubmitting}>
112 {isSubmitting ? 'Logging in...' : 'Log in'}
113 </button>
114 </form>
115 )}
116 </Form>
117 );
118}Window Size Tracker#
1interface WindowSize {
2 width: number;
3 height: number;
4}
5
6interface WindowSizeProps {
7 children: (size: WindowSize) => React.ReactNode;
8}
9
10function WindowSize({ children }: WindowSizeProps) {
11 const [size, setSize] = useState<WindowSize>({
12 width: window.innerWidth,
13 height: window.innerHeight,
14 });
15
16 useEffect(() => {
17 const handleResize = () => {
18 setSize({
19 width: window.innerWidth,
20 height: window.innerHeight,
21 });
22 };
23
24 window.addEventListener('resize', handleResize);
25 return () => window.removeEventListener('resize', handleResize);
26 }, []);
27
28 return <>{children(size)}</>;
29}
30
31// Usage with responsive rendering
32function ResponsiveLayout() {
33 return (
34 <WindowSize>
35 {({ width }) => (
36 width < 768 ? <MobileLayout /> : <DesktopLayout />
37 )}
38 </WindowSize>
39 );
40}Combining Multiple Render Props#
1// Compose multiple render prop components
2function Dashboard() {
3 return (
4 <Auth>
5 {({ user }) => (
6 <Theme>
7 {({ theme }) => (
8 <Fetch<DashboardData> url="/api/dashboard">
9 {({ data, loading }) => (
10 <div className={theme}>
11 <header>Welcome, {user.name}</header>
12 {loading ? (
13 <Spinner />
14 ) : (
15 <DashboardContent data={data} />
16 )}
17 </div>
18 )}
19 </Fetch>
20 )}
21 </Theme>
22 )}
23 </Auth>
24 );
25}
26
27// Using a compose helper
28function Compose({ components, children }: {
29 components: Array<React.ComponentType<{ children: (value: any) => React.ReactNode }>>;
30 children: (...values: any[]) => React.ReactNode;
31}) {
32 return components.reduceRight(
33 (acc, Component) => (values: any[]) => (
34 <Component>
35 {(value) => acc([...values, value])}
36 </Component>
37 ),
38 (values: any[]) => children(...values)
39 )([]);
40}
41
42// Usage
43function Dashboard() {
44 return (
45 <Compose components={[Auth, Theme, WindowSize]}>
46 {(user, theme, windowSize) => (
47 <div className={theme}>
48 <span>User: {user.name}</span>
49 <span>Window: {windowSize.width}x{windowSize.height}</span>
50 </div>
51 )}
52 </Compose>
53 );
54}Converting to Custom Hooks#
1// Render prop to hook
2function useMousePosition() {
3 const [position, setPosition] = useState({ x: 0, y: 0 });
4
5 useEffect(() => {
6 const handleMouseMove = (e: MouseEvent) => {
7 setPosition({ x: e.clientX, y: e.clientY });
8 };
9
10 window.addEventListener('mousemove', handleMouseMove);
11 return () => window.removeEventListener('mousemove', handleMouseMove);
12 }, []);
13
14 return position;
15}
16
17// Hook-based component using render prop
18function MouseTracker({ children }: {
19 children: (position: MousePosition) => React.ReactNode;
20}) {
21 const position = useMousePosition();
22 return <>{children(position)}</>;
23}
24
25// Now users can choose either approach
26function UsingHook() {
27 const { x, y } = useMousePosition();
28 return <div>Mouse: {x}, {y}</div>;
29}
30
31function UsingRenderProp() {
32 return (
33 <MouseTracker>
34 {({ x, y }) => <div>Mouse: {x}, {y}</div>}
35 </MouseTracker>
36 );
37}Performance Optimization#
1// Memoize the render prop callback
2function OptimizedParent() {
3 const renderContent = useCallback(
4 ({ x, y }: MousePosition) => (
5 <div>Mouse: {x}, {y}</div>
6 ),
7 []
8 );
9
10 return <MouseTracker render={renderContent} />;
11}
12
13// Or memoize children in the render prop component
14function MemoizedMouseTracker({ children }: MouseTrackerProps) {
15 const [position, setPosition] = useState<MousePosition>({ x: 0, y: 0 });
16 const childRef = useRef(children);
17 childRef.current = children;
18
19 // ... event handling
20
21 return useMemo(
22 () => childRef.current(position),
23 [position]
24 );
25}Best Practices#
Design:
✓ Keep render prop components focused
✓ Use TypeScript for type safety
✓ Provide sensible defaults
✓ Consider converting to hooks
Performance:
✓ Memoize render prop callbacks
✓ Avoid inline functions in render
✓ Keep state updates minimal
✓ Use useMemo for expensive renders
Patterns:
✓ Use children as function for cleaner JSX
✓ Export both hook and component
✓ Compose multiple render props
✓ Handle loading and error states
Avoid:
✗ Deeply nested render props
✗ Over-complicated state
✗ Missing error boundaries
✗ Ignoring performance
Conclusion#
Render props enable flexible component composition by passing a function that receives data and returns JSX. While hooks have replaced many render prop use cases, the pattern remains valuable for certain scenarios. Consider providing both hook and render prop APIs for maximum flexibility.