Front-end Engineering Lab
PatternsArchitecture Patterns

Render Props vs Hooks

When to use render props, hooks, or both together

Render Props vs Hooks

Understanding when to use render props, custom hooks, or combine both is essential for writing clean React code.

🎯 The Evolution

// 2016: Render Props Era
<DataFetcher url="/api/users">
  {({ data, loading }) => (
    loading ? <Spinner /> : <UserList data={data} />
  )}
</DataFetcher>

// 2019: Hooks Era
function UserList() {
  const { data, loading } = useFetch('/api/users');
  return loading ? <Spinner /> : <List data={data} />;
}

// 2024: Best of Both
function UserList() {
  return (
    <DataFetcher url="/api/users">
      {({ data, loading }) => {
        const processed = useProcessData(data);
        return loading ? <Spinner /> : <List data={processed} />;
      }}
    </DataFetcher>
  );
}

📊 Quick Comparison

FeatureRender PropsHooksCombined
Reusability⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
Readability⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
JSX Control⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
Type Safety⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
Bundle Size⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
Learning Curve⭐⭐⭐⭐⭐⭐⭐⭐⭐

🪝 Pattern 1: Hooks (Preferred for Logic)

Use hooks when you want to reuse logic, not JSX structure.

Basic Custom Hook

function useFetch<T>(url: string) {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);
  
  useEffect(() => {
    let cancelled = false;
    
    fetch(url)
      .then(res => res.json())
      .then(data => {
        if (!cancelled) {
          setData(data);
          setLoading(false);
        }
      })
      .catch(error => {
        if (!cancelled) {
          setError(error);
          setLoading(false);
        }
      });
    
    return () => {
      cancelled = true;
    };
  }, [url]);
  
  return { data, loading, error };
}

// Usage - Clean and simple!
function UserList() {
  const { data, loading, error } = useFetch<User[]>('/api/users');
  
  if (loading) return <Spinner />;
  if (error) return <Error message={error.message} />;
  
  return (
    <ul>
      {data?.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

Advanced Hook with Options

interface UseFetchOptions<T> {
  initialData?: T;
  onSuccess?: (data: T) => void;
  onError?: (error: Error) => void;
  retry?: number;
  enabled?: boolean;
}

function useFetch<T>(
  url: string,
  options: UseFetchOptions<T> = {}
) {
  const {
    initialData,
    onSuccess,
    onError,
    retry = 0,
    enabled = true
  } = options;
  
  const [data, setData] = useState<T | null>(initialData || null);
  const [loading, setLoading] = useState(enabled);
  const [error, setError] = useState<Error | null>(null);
  const retryCount = useRef(0);
  
  const fetchData = useCallback(async () => {
    if (!enabled) return;
    
    setLoading(true);
    setError(null);
    
    try {
      const response = await fetch(url);
      const result = await response.json();
      
      setData(result);
      onSuccess?.(result);
      retryCount.current = 0;
    } catch (err) {
      const error = err as Error;
      
      if (retryCount.current < retry) {
        retryCount.current++;
        setTimeout(fetchData, 1000 * retryCount.current);
      } else {
        setError(error);
        onError?.(error);
      }
    } finally {
      setLoading(false);
    }
  }, [url, enabled, retry, onSuccess, onError]);
  
  useEffect(() => {
    fetchData();
  }, [fetchData]);
  
  const refetch = useCallback(() => {
    retryCount.current = 0;
    fetchData();
  }, [fetchData]);
  
  return { data, loading, error, refetch };
}

// Usage with options
function UserProfile({ userId }: { userId: string }) {
  const { data, loading, refetch } = useFetch<User>(
    `/api/users/${userId}`,
    {
      onSuccess: (user) => {
        console.log('User loaded:', user.name);
      },
      retry: 3,
      enabled: !!userId
    }
  );
  
  return (
    <div>
      {loading ? <Spinner /> : <Profile user={data} />}
      <button onClick={refetch}>Refresh</button>
    </div>
  );
}

🎨 Pattern 2: Render Props (Preferred for JSX Control)

Use render props when you want to control JSX structure from the parent.

Basic Render Prop

interface DataFetcherProps<T> {
  url: string;
  children: (state: {
    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(() => {
    fetch(url)
      .then(res => res.json())
      .then(setData)
      .catch(setError)
      .finally(() => setLoading(false));
  }, [url]);
  
  return children({ data, loading, error });
}

// Usage - Full control over rendering!
<DataFetcher<User[]> url="/api/users">
  {({ data, loading, error }) => {
    if (loading) return <CustomSpinner />;
    if (error) return <CustomError error={error} />;
    
    return (
      <div className="custom-layout">
        <h1>Users ({data?.length})</h1>
        <UserGrid users={data} />
      </div>
    );
  }}
</DataFetcher>

Function as Children (FaCC)

// Same as render props, just different syntax
<DataFetcher url="/api/users">
  {(state) => <UserList {...state} />}
</DataFetcher>

// Also works with render prop
<DataFetcher 
  url="/api/users"
  render={(state) => <UserList {...state} />}
/>

With Slot Pattern

interface MouseTrackerProps {
  children: (position: { x: number; y: number }) => React.ReactNode;
}

function MouseTracker({ children }: MouseTrackerProps) {
  const [position, setPosition] = useState({ x: 0, y: 0 });
  
  useEffect(() => {
    const handleMove = (e: MouseEvent) => {
      setPosition({ x: e.clientX, y: e.clientY });
    };
    
    window.addEventListener('mousemove', handleMove);
    return () => window.removeEventListener('mousemove', handleMove);
  }, []);
  
  return children(position);
}

// Usage - Total control over what to render
<MouseTracker>
  {({ x, y }) => (
    <div style={{ position: 'fixed', left: x, top: y }}>
      <Cursor />
    </div>
  )}
</MouseTracker>

🔄 Pattern 3: Combined (Best of Both Worlds)

Combine hooks and render props for maximum flexibility.

Hook Inside Render Prop

function DataList() {
  return (
    <DataFetcher url="/api/items">
      {({ data, loading }) => {
        // Use hooks inside render prop!
        const filtered = useFilter(data, filterTerm);
        const sorted = useSorting(filtered, sortBy);
        
        if (loading) return <Spinner />;
        
        return <List items={sorted} />;
      }}
    </DataFetcher>
  );
}

Render Prop Component Using Hooks

function MouseTracker({ children }: MouseTrackerProps) {
  // Component uses hooks internally
  const position = useMousePosition();
  const velocity = useMouseVelocity(position);
  
  // But exposes render prop API
  return children({ position, velocity });
}

// Best of both: hook implementation, render prop interface
<MouseTracker>
  {({ position, velocity }) => (
    <Cursor position={position} speed={velocity} />
  )}
</MouseTracker>

Headless Component Pattern

// Component provides logic via render props
function Dropdown({ children }: { children: (api: DropdownAPI) => React.ReactNode }) {
  const [open, setOpen] = useState(false);
  const ref = useClickOutside(() => setOpen(false));
  
  const api = {
    open,
    setOpen,
    toggle: () => setOpen(!open),
    ref
  };
  
  return children(api);
}

// Usage with full styling control
<Dropdown>
  {({ open, toggle, ref }) => (
    <div ref={ref} className="my-dropdown">
      <button onClick={toggle}>Menu</button>
      {open && (
        <div className="my-menu">
          <MenuItem>Option 1</MenuItem>
          <MenuItem>Option 2</MenuItem>
        </div>
      )}
    </div>
  )}
</Dropdown>

🎯 When to Use What?

Use Hooks When:

// ✅ Reusing LOGIC only
const { data, loading } = useFetch(url);
const authenticated = useAuth();
const { width, height } = useWindowSize();

// ✅ Composing multiple hooks
function useUserData(userId: string) {
  const { data: user } = useFetch(`/users/${userId}`);
  const { data: posts } = useFetch(`/users/${userId}/posts`);
  const permissions = usePermissions(user);
  
  return { user, posts, permissions };
}

// ✅ Simple state management
const [count, setCount] = useState(0);
const theme = useTheme();

Use Render Props When:

// ✅ User needs control over JSX structure
<DataFetcher url={url}>
  {({ data }) => (
    <CustomLayout>
      <CustomHeader data={data} />
      <CustomContent data={data} />
    </CustomLayout>
  )}
</DataFetcher>

// ✅ Multiple render slots
<SplitView>
  {({ Left, Right }) => (
    <>
      <Left><Sidebar /></Left>
      <Right><Content /></Right>
    </>
  )}
</SplitView>

// ✅ Animation libraries
<Motion>
  {({ x, y, opacity }) => (
    <div style={{ transform: `translate(${x}px, ${y}px)`, opacity }}>
      Animated content
    </div>
  )}
</Motion>

Use Both When:

// ✅ Library components (Radix, Headless UI)
<Tooltip>
  {({ open }) => {
    const animation = useAnimation(open);
    return <TooltipContent style={animation} />;
  }}
</Tooltip>

// ✅ Complex components with hooks inside
<Form onSubmit={handleSubmit}>
  {({ register, errors }) => {
    const validation = useValidation();
    return <FormFields register={register} errors={errors} />;
  }}
</Form>

🏢 Real-World Examples

React Query (Hooks)

// Hooks-based API
const { data, isLoading } = useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers
});

Downshift (Render Props)

// Render props for full control
<Downshift>
  {({ getInputProps, getItemProps, isOpen }) => (
    <div>
      <input {...getInputProps()} />
      {isOpen && (
        <ul>
          {items.map((item, index) => (
            <li {...getItemProps({ item, index })}>{item}</li>
          ))}
        </ul>
      )}
    </div>
  )}
</Downshift>

Framer Motion (Both)

// Provides both hooks and render props
const controls = useAnimation(); // Hook

<motion.div animate={controls}> // Component
  {/* Or render prop */}
</motion.div>

📚 Key Takeaways

  1. Hooks for logic - Reuse stateful behavior
  2. Render props for structure - Control JSX layout
  3. Combine both - Maximum flexibility
  4. Hooks are simpler - Prefer when possible
  5. Render props for libraries - When users need control
  6. TypeScript works better with hooks
  7. Bundle size - Hooks are smaller

Modern rule of thumb: Start with hooks, add render props only when users need JSX control.

On this page