Back to Blog
ReactRender PropsPatternsComponents

React Render Props Patterns

Share component logic with render props. From basic patterns to hook integration to practical use cases.

B
Bootspring Team
Engineering
November 12, 2021
7 min read

Render props enable flexible component composition. Here's how to use them effectively alongside modern hooks.

Basic Render Props#

1// Basic render prop pattern 2interface MousePosition { 3 x: number; 4 y: number; 5} 6 7interface MouseTrackerProps { 8 children: (position: MousePosition) => React.ReactNode; 9} 10 11function MouseTracker({ children }: MouseTrackerProps) { 12 const [position, setPosition] = useState<MousePosition>({ x: 0, y: 0 }); 13 14 useEffect(() => { 15 const handleMouseMove = (event: MouseEvent) => { 16 setPosition({ x: event.clientX, y: event.clientY }); 17 }; 18 19 window.addEventListener('mousemove', handleMouseMove); 20 return () => window.removeEventListener('mousemove', handleMouseMove); 21 }, []); 22 23 return <>{children(position)}</>; 24} 25 26// Usage 27<MouseTracker> 28 {({ x, y }) => ( 29 <div> 30 Mouse position: {x}, {y} 31 </div> 32 )} 33</MouseTracker> 34 35// Alternative: render prop 36interface MouseTrackerAltProps { 37 render: (position: MousePosition) => React.ReactNode; 38} 39 40function MouseTrackerAlt({ render }: MouseTrackerAltProps) { 41 const [position, setPosition] = useState({ x: 0, y: 0 }); 42 // ... same logic 43 return <>{render(position)}</>; 44} 45 46<MouseTrackerAlt render={({ x, y }) => <span>{x}, {y}</span>} />

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('Fetch 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 40<Fetch<User[]> url="/api/users"> 41 {({ data, loading, error, refetch }) => { 42 if (loading) return <Spinner />; 43 if (error) return <Error message={error.message} onRetry={refetch} />; 44 return <UserList users={data!} />; 45 }} 46</Fetch>

Toggle Component#

1interface ToggleRenderProps { 2 isOn: boolean; 3 toggle: () => void; 4 setOn: () => void; 5 setOff: () => void; 6} 7 8interface ToggleProps { 9 initialOn?: boolean; 10 onToggle?: (isOn: boolean) => void; 11 children: (props: ToggleRenderProps) => React.ReactNode; 12} 13 14function Toggle({ initialOn = false, onToggle, children }: ToggleProps) { 15 const [isOn, setIsOn] = useState(initialOn); 16 17 const toggle = useCallback(() => { 18 setIsOn((prev) => { 19 const next = !prev; 20 onToggle?.(next); 21 return next; 22 }); 23 }, [onToggle]); 24 25 const setOn = useCallback(() => { 26 setIsOn(true); 27 onToggle?.(true); 28 }, [onToggle]); 29 30 const setOff = useCallback(() => { 31 setIsOn(false); 32 onToggle?.(false); 33 }, [onToggle]); 34 35 return <>{children({ isOn, toggle, setOn, setOff })}</>; 36} 37 38// Usage 39<Toggle onToggle={(isOn) => console.log('Toggled:', isOn)}> 40 {({ isOn, toggle }) => ( 41 <button onClick={toggle}> 42 {isOn ? 'ON' : 'OFF'} 43 </button> 44 )} 45</Toggle>

Form State Management#

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

Headless Components#

1// Headless dropdown 2interface DropdownRenderProps { 3 isOpen: boolean; 4 selectedItem: string | null; 5 highlightedIndex: number; 6 getToggleProps: () => React.ButtonHTMLAttributes<HTMLButtonElement>; 7 getMenuProps: () => React.HTMLAttributes<HTMLUListElement>; 8 getItemProps: (index: number, item: string) => React.LiHTMLAttributes<HTMLLIElement>; 9} 10 11interface DropdownProps { 12 items: string[]; 13 onSelect: (item: string) => void; 14 children: (props: DropdownRenderProps) => React.ReactNode; 15} 16 17function Dropdown({ items, onSelect, children }: DropdownProps) { 18 const [isOpen, setIsOpen] = useState(false); 19 const [selectedItem, setSelectedItem] = useState<string | null>(null); 20 const [highlightedIndex, setHighlightedIndex] = useState(0); 21 22 const getToggleProps = () => ({ 23 onClick: () => setIsOpen(!isOpen), 24 'aria-haspopup': 'listbox' as const, 25 'aria-expanded': isOpen, 26 }); 27 28 const getMenuProps = () => ({ 29 role: 'listbox' as const, 30 'aria-activedescendant': isOpen ? `item-${highlightedIndex}` : undefined, 31 }); 32 33 const getItemProps = (index: number, item: string) => ({ 34 id: `item-${index}`, 35 role: 'option' as const, 36 'aria-selected': selectedItem === item, 37 onClick: () => { 38 setSelectedItem(item); 39 onSelect(item); 40 setIsOpen(false); 41 }, 42 onMouseEnter: () => setHighlightedIndex(index), 43 }); 44 45 return ( 46 <> 47 {children({ 48 isOpen, 49 selectedItem, 50 highlightedIndex, 51 getToggleProps, 52 getMenuProps, 53 getItemProps, 54 })} 55 </> 56 ); 57} 58 59// Usage with custom styling 60<Dropdown items={['Apple', 'Banana', 'Orange']} onSelect={console.log}> 61 {({ isOpen, selectedItem, getToggleProps, getMenuProps, getItemProps, highlightedIndex }) => ( 62 <div className="dropdown"> 63 <button {...getToggleProps()} className="dropdown-toggle"> 64 {selectedItem || 'Select fruit'} 65 </button> 66 {isOpen && ( 67 <ul {...getMenuProps()} className="dropdown-menu"> 68 {['Apple', 'Banana', 'Orange'].map((item, index) => ( 69 <li 70 key={item} 71 {...getItemProps(index, item)} 72 className={highlightedIndex === index ? 'highlighted' : ''} 73 > 74 {item} 75 </li> 76 ))} 77 </ul> 78 )} 79 </div> 80 )} 81</Dropdown>

Converting to Hooks#

1// Render prop component 2function WindowSize({ children }: { children: (size: Size) => React.ReactNode }) { 3 const [size, setSize] = useState({ width: 0, height: 0 }); 4 5 useEffect(() => { 6 const handleResize = () => { 7 setSize({ width: window.innerWidth, height: window.innerHeight }); 8 }; 9 handleResize(); 10 window.addEventListener('resize', handleResize); 11 return () => window.removeEventListener('resize', handleResize); 12 }, []); 13 14 return <>{children(size)}</>; 15} 16 17// Equivalent hook 18function useWindowSize(): Size { 19 const [size, setSize] = useState({ width: 0, height: 0 }); 20 21 useEffect(() => { 22 const handleResize = () => { 23 setSize({ width: window.innerWidth, height: window.innerHeight }); 24 }; 25 handleResize(); 26 window.addEventListener('resize', handleResize); 27 return () => window.removeEventListener('resize', handleResize); 28 }, []); 29 30 return size; 31} 32 33// Render prop wrapper around hook (for flexibility) 34function WindowSizeRenderProp({ children }: { children: (size: Size) => React.ReactNode }) { 35 const size = useWindowSize(); 36 return <>{children(size)}</>; 37}

Best Practices#

When to Use: ✓ Cross-cutting concerns ✓ Headless UI components ✓ Dynamic rendering logic ✓ Component inversion of control Design: ✓ Keep render props focused ✓ Memoize callbacks ✓ Provide sensible defaults ✓ Type render prop arguments Alternatives: ✓ Use hooks for stateful logic ✓ Use composition for structure ✓ Use context for deep sharing ✓ Combine patterns as needed

Conclusion#

Render props enable powerful component composition by inverting control to the consumer. While hooks have replaced many render prop use cases, they remain valuable for headless components and flexible APIs. Use them alongside hooks for maximum flexibility.

Share this article

Help spread the word about Bootspring