Back to Blog
ReactRender PropsPatternsCode Reuse

React Render Props Pattern

Share component logic with render props. From data fetching to mouse tracking to form handling.

B
Bootspring Team
Engineering
April 22, 2021
7 min read

Render props share code between components using a function prop. Here's how to use the pattern effectively.

Basic Render Props#

1// Component with render prop 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({ 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} 38 39// Children as render prop (common pattern) 40interface MouseTrackerChildrenProps { 41 children: (position: MousePosition) => React.ReactNode; 42} 43 44function MouseTrackerChildren({ children }: MouseTrackerChildrenProps) { 45 const [position, setPosition] = useState({ x: 0, y: 0 }); 46 // ... same logic 47 return <>{children(position)}</>; 48} 49 50// Usage 51<MouseTrackerChildren> 52 {({ x, y }) => <div>Position: {x}, {y}</div>} 53</MouseTrackerChildren>

Data Fetching with Render Props#

1interface FetchState<T> { 2 data: T | null; 3 loading: boolean; 4 error: Error | null; 5 refetch: () => void; 6} 7 8interface FetchProps<T> { 9 url: string; 10 children: (state: FetchState<T>) => React.ReactNode; 11} 12 13function Fetch<T>({ url, children }: FetchProps<T>) { 14 const [data, setData] = useState<T | null>(null); 15 const [loading, setLoading] = useState(true); 16 const [error, setError] = useState<Error | null>(null); 17 18 const fetchData = useCallback(async () => { 19 setLoading(true); 20 setError(null); 21 22 try { 23 const response = await fetch(url); 24 if (!response.ok) throw new Error('Fetch failed'); 25 const json = await response.json(); 26 setData(json); 27 } catch (err) { 28 setError(err as Error); 29 } finally { 30 setLoading(false); 31 } 32 }, [url]); 33 34 useEffect(() => { 35 fetchData(); 36 }, [fetchData]); 37 38 return <>{children({ data, loading, error, refetch: fetchData })}</>; 39} 40 41// Usage 42function UserProfile({ userId }: { userId: string }) { 43 return ( 44 <Fetch<User> url={`/api/users/${userId}`}> 45 {({ data, loading, error, refetch }) => { 46 if (loading) return <Spinner />; 47 if (error) return <Error message={error.message} onRetry={refetch} />; 48 if (!data) return null; 49 50 return ( 51 <div> 52 <h1>{data.name}</h1> 53 <p>{data.email}</p> 54 </div> 55 ); 56 }} 57 </Fetch> 58 ); 59}

Toggle Component#

1interface ToggleState { 2 on: boolean; 3 toggle: () => void; 4 setOn: () => void; 5 setOff: () => void; 6} 7 8interface ToggleProps { 9 initialOn?: boolean; 10 children: (state: ToggleState) => React.ReactNode; 11} 12 13function Toggle({ initialOn = false, children }: ToggleProps) { 14 const [on, setOn] = useState(initialOn); 15 16 const state: ToggleState = { 17 on, 18 toggle: () => setOn(prev => !prev), 19 setOn: () => setOn(true), 20 setOff: () => setOn(false), 21 }; 22 23 return <>{children(state)}</>; 24} 25 26// Usage 27function App() { 28 return ( 29 <Toggle initialOn={false}> 30 {({ on, toggle }) => ( 31 <div> 32 <p>Toggle is {on ? 'ON' : 'OFF'}</p> 33 <button onClick={toggle}>Toggle</button> 34 </div> 35 )} 36 </Toggle> 37 ); 38} 39 40// Multiple toggles 41function Settings() { 42 return ( 43 <Toggle> 44 {({ on: darkMode, toggle: toggleDark }) => ( 45 <Toggle> 46 {({ on: notifications, toggle: toggleNotifications }) => ( 47 <div> 48 <label> 49 <input 50 type="checkbox" 51 checked={darkMode} 52 onChange={toggleDark} 53 /> 54 Dark Mode 55 </label> 56 <label> 57 <input 58 type="checkbox" 59 checked={notifications} 60 onChange={toggleNotifications} 61 /> 62 Notifications 63 </label> 64 </div> 65 )} 66 </Toggle> 67 )} 68 </Toggle> 69 ); 70}

Form Handling#

1interface FormState<T> { 2 values: T; 3 errors: Partial<Record<keyof T, string>>; 4 touched: Partial<Record<keyof T, boolean>>; 5 handleChange: (name: keyof T, value: any) => void; 6 handleBlur: (name: keyof T) => void; 7 handleSubmit: (e: React.FormEvent) => void; 8 isValid: boolean; 9 isSubmitting: boolean; 10} 11 12interface FormProps<T> { 13 initialValues: T; 14 validate?: (values: T) => Partial<Record<keyof T, string>>; 15 onSubmit: (values: T) => Promise<void> | void; 16 children: (state: FormState<T>) => React.ReactNode; 17} 18 19function Form<T extends Record<string, any>>({ 20 initialValues, 21 validate, 22 onSubmit, 23 children, 24}: FormProps<T>) { 25 const [values, setValues] = useState<T>(initialValues); 26 const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({}); 27 const [touched, setTouched] = useState<Partial<Record<keyof T, boolean>>>({}); 28 const [isSubmitting, setIsSubmitting] = useState(false); 29 30 const handleChange = (name: keyof T, value: any) => { 31 setValues(prev => ({ ...prev, [name]: value })); 32 }; 33 34 const handleBlur = (name: keyof T) => { 35 setTouched(prev => ({ ...prev, [name]: true })); 36 if (validate) { 37 setErrors(validate(values)); 38 } 39 }; 40 41 const handleSubmit = async (e: React.FormEvent) => { 42 e.preventDefault(); 43 44 if (validate) { 45 const validationErrors = validate(values); 46 setErrors(validationErrors); 47 if (Object.keys(validationErrors).length > 0) return; 48 } 49 50 setIsSubmitting(true); 51 try { 52 await onSubmit(values); 53 } finally { 54 setIsSubmitting(false); 55 } 56 }; 57 58 const isValid = Object.keys(errors).length === 0; 59 60 return ( 61 <form onSubmit={handleSubmit}> 62 {children({ 63 values, 64 errors, 65 touched, 66 handleChange, 67 handleBlur, 68 handleSubmit, 69 isValid, 70 isSubmitting, 71 })} 72 </form> 73 ); 74} 75 76// Usage 77interface LoginValues { 78 email: string; 79 password: string; 80} 81 82function LoginForm() { 83 return ( 84 <Form<LoginValues> 85 initialValues={{ email: '', password: '' }} 86 validate={(values) => { 87 const errors: Partial<Record<keyof LoginValues, string>> = {}; 88 if (!values.email) errors.email = 'Required'; 89 if (!values.password) errors.password = 'Required'; 90 return errors; 91 }} 92 onSubmit={async (values) => { 93 await login(values); 94 }} 95 > 96 {({ values, errors, touched, handleChange, handleBlur, isSubmitting }) => ( 97 <> 98 <input 99 value={values.email} 100 onChange={e => handleChange('email', e.target.value)} 101 onBlur={() => handleBlur('email')} 102 /> 103 {touched.email && errors.email && <span>{errors.email}</span>} 104 105 <input 106 type="password" 107 value={values.password} 108 onChange={e => handleChange('password', e.target.value)} 109 onBlur={() => handleBlur('password')} 110 /> 111 {touched.password && errors.password && <span>{errors.password}</span>} 112 113 <button type="submit" disabled={isSubmitting}> 114 {isSubmitting ? 'Logging in...' : 'Login'} 115 </button> 116 </> 117 )} 118 </Form> 119 ); 120}

Scroll Position#

1interface ScrollState { 2 x: number; 3 y: number; 4 direction: 'up' | 'down' | null; 5 isAtTop: boolean; 6 isAtBottom: boolean; 7} 8 9interface ScrollTrackerProps { 10 children: (state: ScrollState) => React.ReactNode; 11} 12 13function ScrollTracker({ children }: ScrollTrackerProps) { 14 const [state, setState] = useState<ScrollState>({ 15 x: 0, 16 y: 0, 17 direction: null, 18 isAtTop: true, 19 isAtBottom: false, 20 }); 21 22 useEffect(() => { 23 let lastY = window.scrollY; 24 25 const handleScroll = () => { 26 const y = window.scrollY; 27 const x = window.scrollX; 28 const direction = y > lastY ? 'down' : y < lastY ? 'up' : null; 29 const isAtTop = y === 0; 30 const isAtBottom = 31 y + window.innerHeight >= document.documentElement.scrollHeight; 32 33 setState({ x, y, direction, isAtTop, isAtBottom }); 34 lastY = y; 35 }; 36 37 window.addEventListener('scroll', handleScroll, { passive: true }); 38 return () => window.removeEventListener('scroll', handleScroll); 39 }, []); 40 41 return <>{children(state)}</>; 42} 43 44// Usage: Hide header on scroll down 45function App() { 46 return ( 47 <ScrollTracker> 48 {({ direction, isAtTop }) => ( 49 <header 50 style={{ 51 position: 'fixed', 52 transform: direction === 'down' && !isAtTop 53 ? 'translateY(-100%)' 54 : 'translateY(0)', 55 transition: 'transform 0.3s', 56 }} 57 > 58 Navigation 59 </header> 60 )} 61 </ScrollTracker> 62 ); 63}

Window Size#

1interface WindowSize { 2 width: number; 3 height: number; 4 isMobile: boolean; 5 isTablet: boolean; 6 isDesktop: boolean; 7} 8 9interface WindowSizeProps { 10 children: (size: WindowSize) => React.ReactNode; 11} 12 13function WindowSize({ children }: WindowSizeProps) { 14 const [size, setSize] = useState<WindowSize>({ 15 width: window.innerWidth, 16 height: window.innerHeight, 17 isMobile: window.innerWidth < 768, 18 isTablet: window.innerWidth >= 768 && window.innerWidth < 1024, 19 isDesktop: window.innerWidth >= 1024, 20 }); 21 22 useEffect(() => { 23 const handleResize = () => { 24 const width = window.innerWidth; 25 const height = window.innerHeight; 26 27 setSize({ 28 width, 29 height, 30 isMobile: width < 768, 31 isTablet: width >= 768 && width < 1024, 32 isDesktop: width >= 1024, 33 }); 34 }; 35 36 window.addEventListener('resize', handleResize); 37 return () => window.removeEventListener('resize', handleResize); 38 }, []); 39 40 return <>{children(size)}</>; 41} 42 43// Usage 44<WindowSize> 45 {({ isMobile, isDesktop }) => ( 46 isMobile ? <MobileNav /> : <DesktopNav /> 47 )} 48</WindowSize>

Render Props vs Hooks#

1// Render prop version 2function MouseTracker({ render }) { 3 const [position, setPosition] = useState({ x: 0, y: 0 }); 4 // ... event listeners 5 return render(position); 6} 7 8// Hook version (usually preferred) 9function useMousePosition() { 10 const [position, setPosition] = useState({ x: 0, y: 0 }); 11 // ... event listeners 12 return position; 13} 14 15// Usage comparison 16// Render props 17<MouseTracker render={({ x, y }) => <div>{x}, {y}</div>} /> 18 19// Hooks 20function Component() { 21 const { x, y } = useMousePosition(); 22 return <div>{x}, {y}</div>; 23} 24 25// When render props are still useful: 26// 1. Rendering multiple children 27// 2. Third-party library integration 28// 3. Backwards compatibility

Best Practices#

Design: ✓ Use children as function when possible ✓ Provide TypeScript generics ✓ Keep render prop focused ✓ Consider hooks as alternative Performance: ✓ Memoize render functions ✓ Avoid creating functions in render ✓ Use useCallback for handlers ✓ Extract static JSX Naming: ✓ Use 'render' or 'children' for prop name ✓ Document expected function signature ✓ Return descriptive state objects ✓ Include helper functions in state

Conclusion#

Render props enable flexible code sharing between components. While hooks have replaced many render prop use cases, the pattern remains valuable for complex rendering logic and backwards compatibility. Use TypeScript for type safety and memoization for performance.

Share this article

Help spread the word about Bootspring