Render Props

Render Props Pattern

The Render Props pattern is a technique for sharing code between React components using a prop whose value is a function. It's a way to make components more reusable by allowing the consumer to control what gets rendered.

Basic Render Props

Simple Mouse Tracker

// Component that tracks mouse position
function MouseTracker({ render }) {
  const [position, setPosition] = useState({ x: 0, y: 0 });
  
  useEffect(() => {
    function handleMouseMove(event) {
      setPosition({
        x: event.clientX,
        y: event.clientY
      });
    }
    
    document.addEventListener('mousemove', handleMouseMove);
    
    return () => {
      document.removeEventListener('mousemove', handleMouseMove);
    };
  }, []);
  
  return render(position);
}

// Usage
function App() {
  return (
    <div>
      <MouseTracker
        render={({ x, y }) => (
          <h1>Mouse position: {x}, {y}</h1>
        )}
      />
      
      <MouseTracker
        render={({ x, y }) => (
          <div
            style={{
              position: 'absolute',
              left: x,
              top: y,
              width: 10,
              height: 10,
              backgroundColor: 'red',
              borderRadius: '50%',
              transform: 'translate(-50%, -50%)'
            }}
          />
        )}
      />
    </div>
  );
}

Children as Function Pattern

// Using children prop as render function
function DataFetcher({ url, children }) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    let cancelled = false;
    
    const fetchData = async () => {
      try {
        setLoading(true);
        setError(null);
        
        const response = await fetch(url);
        if (!response.ok) throw new Error('Failed to fetch');
        
        const result = await response.json();
        
        if (!cancelled) {
          setData(result);
        }
      } catch (err) {
        if (!cancelled) {
          setError(err.message);
        }
      } finally {
        if (!cancelled) {
          setLoading(false);
        }
      }
    };
    
    fetchData();
    
    return () => {
      cancelled = true;
    };
  }, [url]);
  
  return children({ data, loading, error });
}

// Usage
function UserList() {
  return (
    <DataFetcher url="/api/users">
      {({ data: users, loading, error }) => {
        if (loading) return <div>Loading users...</div>;
        if (error) return <div>Error: {error}</div>;
        if (!users) return <div>No users found</div>;
        
        return (
          <ul>
            {users.map(user => (
              <li key={user.id}>{user.name}</li>
            ))}
          </ul>
        );
      }}
    </DataFetcher>
  );
}

Form Management with Render Props

Generic Form Handler

function Form({ initialValues = {}, onSubmit, children }) {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState({});
  const [touched, setTouched] = useState({});
  const [isSubmitting, setIsSubmitting] = useState(false);
  
  const handleChange = (name, value) => {
    setValues(prev => ({ ...prev, [name]: value }));
    
    // Clear error when user starts typing
    if (errors[name]) {
      setErrors(prev => ({ ...prev, [name]: '' }));
    }
  };
  
  const handleBlur = (name) => {
    setTouched(prev => ({ ...prev, [name]: true }));
  };
  
  const setFieldError = (name, error) => {
    setErrors(prev => ({ ...prev, [name]: error }));
  };
  
  const handleSubmit = async (event) => {
    event.preventDefault();
    setIsSubmitting(true);
    
    try {
      await onSubmit(values);
    } catch (error) {
      console.error('Form submission error:', error);
    } finally {
      setIsSubmitting(false);
    }
  };
  
  const formProps = {
    values,
    errors,
    touched,
    isSubmitting,
    handleChange,
    handleBlur,
    setFieldError,
    handleSubmit
  };
  
  return (
    <form onSubmit={handleSubmit}>
      {children(formProps)}
    </form>
  );
}

// Usage
function LoginForm() {
  const handleSubmit = async (values) => {
    // Validate
    if (!values.email) throw new Error('Email is required');
    if (!values.password) throw new Error('Password is required');
    
    // Submit
    await loginUser(values);
  };
  
  return (
    <Form
      initialValues={{ email: '', password: '' }}
      onSubmit={handleSubmit}
    >
      {({ values, errors, touched, isSubmitting, handleChange, handleBlur }) => (
        <>
          <div>
            <input
              type="email"
              value={values.email}
              onChange={(e) => handleChange('email', e.target.value)}
              onBlur={() => handleBlur('email')}
              placeholder="Email"
            />
            {touched.email && errors.email && (
              <span className="error">{errors.email}</span>
            )}
          </div>
          
          <div>
            <input
              type="password"
              value={values.password}
              onChange={(e) => handleChange('password', e.target.value)}
              onBlur={() => handleBlur('password')}
              placeholder="Password"
            />
            {touched.password && errors.password && (
              <span className="error">{errors.password}</span>
            )}
          </div>
          
          <button type="submit" disabled={isSubmitting}>
            {isSubmitting ? 'Logging in...' : 'Login'}
          </button>
        </>
      )}
    </Form>
  );
}

Toggle Component

Reusable Toggle Logic

function Toggle({ initialToggled = false, children }) {
  const [toggled, setToggled] = useState(initialToggled);
  
  const toggle = () => setToggled(prev => !prev);
  const setToggle = (value) => setToggled(value);
  
  return children({
    toggled,
    toggle,
    setToggle
  });
}

// Usage examples
function App() {
  return (
    <div>
      {/* Modal Toggle */}
      <Toggle>
        {({ toggled, toggle }) => (
          <>
            <button onClick={toggle}>
              {toggled ? 'Hide' : 'Show'} Modal
            </button>
            {toggled && (
              <div className="modal">
                <div className="modal-content">
                  <h2>Modal Content</h2>
                  <button onClick={toggle}>Close</button>
                </div>
              </div>
            )}
          </>
        )}
      </Toggle>
      
      {/* Theme Toggle */}
      <Toggle initialToggled={false}>
        {({ toggled: isDark, toggle: toggleTheme }) => (
          <div style={{
            backgroundColor: isDark ? '#333' : '#fff',
            color: isDark ? '#fff' : '#333',
            padding: '20px'
          }}>
            <button onClick={toggleTheme}>
              Switch to {isDark ? 'Light' : 'Dark'} Theme
            </button>
            <p>Current theme: {isDark ? 'Dark' : 'Light'}</p>
          </div>
        )}
      </Toggle>
      
      {/* Accordion Toggle */}
      <Toggle>
        {({ toggled: isExpanded, toggle: toggleExpanded }) => (
          <div className="accordion">
            <button onClick={toggleExpanded}>
              {isExpanded ? '▼' : '▶'} Click to expand
            </button>
            {isExpanded && (
              <div className="accordion-content">
                <p>This content is now visible!</p>
              </div>
            )}
          </div>
        )}
      </Toggle>
    </div>
  );
}

Data Loading with Render Props

Async Data Component

function AsyncData({ 
  promise, 
  deps = [], 
  children,
  loadingComponent = <div>Loading...</div>,
  errorComponent = null
}) {
  const [state, setState] = useState({
    data: null,
    loading: true,
    error: null
  });
  
  useEffect(() => {
    let cancelled = false;
    
    setState({ data: null, loading: true, error: null });
    
    Promise.resolve(promise)
      .then(data => {
        if (!cancelled) {
          setState({ data, loading: false, error: null });
        }
      })
      .catch(error => {
        if (!cancelled) {
          setState({ data: null, loading: false, error });
        }
      });
    
    return () => {
      cancelled = true;
    };
  }, deps);
  
  const refetch = () => {
    setState(prev => ({ ...prev, loading: true, error: null }));
    
    Promise.resolve(promise)
      .then(data => setState({ data, loading: false, error: null }))
      .catch(error => setState({ data: null, loading: false, error }));
  };
  
  if (state.loading) {
    return loadingComponent;
  }
  
  if (state.error) {
    if (errorComponent) {
      return errorComponent;
    }
    return (
      <div>
        <p>Error: {state.error.message}</p>
        <button onClick={refetch}>Retry</button>
      </div>
    );
  }
  
  return children({ data: state.data, refetch });
}

// Usage
function UserProfile({ userId }) {
  return (
    <AsyncData
      promise={fetchUser(userId)}
      deps={[userId]}
      loadingComponent={<div>Loading user profile...</div>}
    >
      {({ data: user, refetch }) => (
        <div>
          <h1>{user.name}</h1>
          <p>Email: {user.email}</p>
          <p>Joined: {new Date(user.joinDate).toLocaleDateString()}</p>
          <button onClick={refetch}>Refresh</button>
        </div>
      )}
    </AsyncData>
  );
}

// Multiple async operations
function Dashboard() {
  return (
    <div>
      <AsyncData promise={fetchUser()}>
        {({ data: user }) => (
          <div>
            <h1>Welcome, {user.name}!</h1>
            
            <AsyncData promise={fetchPosts(user.id)} deps={[user.id]}>
              {({ data: posts }) => (
                <div>
                  <h2>Your Posts</h2>
                  {posts.map(post => (
                    <article key={post.id}>
                      <h3>{post.title}</h3>
                      <p>{post.excerpt}</p>
                    </article>
                  ))}
                </div>
              )}
            </AsyncData>
          </div>
        )}
      </AsyncData>
    </div>
  );
}

Intersection Observer

Visibility Detection

function IntersectionObserver({ 
  children, 
  threshold = 0.1, 
  rootMargin = '0px',
  triggerOnce = false 
}) {
  const [isIntersecting, setIsIntersecting] = useState(false);
  const [hasTriggered, setHasTriggered] = useState(false);
  const targetRef = useRef();
  
  useEffect(() => {
    const observer = new window.IntersectionObserver(
      ([entry]) => {
        const isVisible = entry.isIntersecting;
        setIsIntersecting(isVisible);
        
        if (isVisible && triggerOnce && !hasTriggered) {
          setHasTriggered(true);
        }
      },
      { threshold, rootMargin }
    );
    
    if (targetRef.current) {
      observer.observe(targetRef.current);
    }
    
    return () => {
      if (targetRef.current) {
        observer.unobserve(targetRef.current);
      }
    };
  }, [threshold, rootMargin, triggerOnce, hasTriggered]);
  
  const shouldShow = triggerOnce ? (isIntersecting || hasTriggered) : isIntersecting;
  
  return children({
    isIntersecting: shouldShow,
    targetRef
  });
}

// Usage for lazy loading
function LazyImage({ src, alt }) {
  return (
    <IntersectionObserver triggerOnce>
      {({ isIntersecting, targetRef }) => (
        <div ref={targetRef} style={{ minHeight: '200px' }}>
          {isIntersecting ? (
            <img src={src} alt={alt} />
          ) : (
            <div>Loading image...</div>
          )}
        </div>
      )}
    </IntersectionObserver>
  );
}

// Usage for animations
function AnimateOnScroll({ children }) {
  return (
    <IntersectionObserver threshold={0.5}>
      {({ isIntersecting, targetRef }) => (
        <div
          ref={targetRef}
          style={{
            opacity: isIntersecting ? 1 : 0,
            transform: isIntersecting ? 'translateY(0)' : 'translateY(20px)',
            transition: 'opacity 0.5s, transform 0.5s'
          }}
        >
          {children}
        </div>
      )}
    </IntersectionObserver>
  );
}

Window Dimensions

Responsive Render Props

function WindowDimensions({ children }) {
  const [dimensions, setDimensions] = useState({
    width: window.innerWidth,
    height: window.innerHeight
  });
  
  useEffect(() => {
    function handleResize() {
      setDimensions({
        width: window.innerWidth,
        height: window.innerHeight
      });
    }
    
    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, []);
  
  const isMobile = dimensions.width <= 768;
  const isTablet = dimensions.width > 768 && dimensions.width <= 1024;
  const isDesktop = dimensions.width > 1024;
  
  return children({
    ...dimensions,
    isMobile,
    isTablet,
    isDesktop
  });
}

// Usage
function ResponsiveLayout() {
  return (
    <WindowDimensions>
      {({ width, height, isMobile, isTablet, isDesktop }) => (
        <div>
          <p>Screen: {width} x {height}</p>
          
          {isMobile && (
            <MobileNavigation />
          )}
          
          {isTablet && (
            <TabletLayout>
              <SidebarContent />
              <MainContent />
            </TabletLayout>
          )}
          
          {isDesktop && (
            <DesktopLayout>
              <Sidebar />
              <MainContent />
              <RightPanel />
            </DesktopLayout>
          )}
        </div>
      )}
    </WindowDimensions>
  );
}

Geolocation

Location-Based Render Props

function Geolocation({ children, options = {} }) {
  const [location, setLocation] = useState(null);
  const [error, setError] = useState(null);
  const [loading, setLoading] = useState(true);
  
  useEffect(() => {
    if (!navigator.geolocation) {
      setError(new Error('Geolocation is not supported'));
      setLoading(false);
      return;
    }
    
    const watchId = navigator.geolocation.watchPosition(
      (position) => {
        setLocation({
          latitude: position.coords.latitude,
          longitude: position.coords.longitude,
          accuracy: position.coords.accuracy,
          timestamp: position.timestamp
        });
        setError(null);
        setLoading(false);
      },
      (error) => {
        setError(error);
        setLoading(false);
      },
      {
        enableHighAccuracy: true,
        timeout: 10000,
        maximumAge: 60000,
        ...options
      }
    );
    
    return () => {
      navigator.geolocation.clearWatch(watchId);
    };
  }, []);
  
  return children({ location, error, loading });
}

// Usage
function LocationApp() {
  return (
    <Geolocation>
      {({ location, error, loading }) => {
        if (loading) return <div>Getting your location...</div>;
        if (error) return <div>Error: {error.message}</div>;
        if (!location) return <div>No location available</div>;
        
        return (
          <div>
            <h2>Your Location</h2>
            <p>Latitude: {location.latitude.toFixed(6)}</p>
            <p>Longitude: {location.longitude.toFixed(6)}</p>
            <p>Accuracy: {location.accuracy} meters</p>
            
            <MapComponent
              lat={location.latitude}
              lng={location.longitude}
            />
          </div>
        );
      }}
    </Geolocation>
  );
}

Render Props Best Practices

Performance Optimization

// ❌ Creates new function on every render
function BadParent() {
  return (
    <DataFetcher url="/api/data">
      {({ data, loading }) => {
        if (loading) return <div>Loading...</div>;
        return <div>{data?.name}</div>;
      }}
    </DataFetcher>
  );
}

// ✅ Stable function reference
function GoodParent() {
  const renderData = useCallback(({ data, loading }) => {
    if (loading) return <div>Loading...</div>;
    return <div>{data?.name}</div>;
  }, []);
  
  return (
    <DataFetcher url="/api/data">
      {renderData}
    </DataFetcher>
  );
}

// ✅ Or extract to separate component
function DataRenderer({ data, loading }) {
  if (loading) return <div>Loading...</div>;
  return <div>{data?.name}</div>;
}

function OptimalParent() {
  return (
    <DataFetcher url="/api/data" component={DataRenderer} />
  );
}

Compound Render Props

function Modal({ children }) {
  const [isOpen, setIsOpen] = useState(false);
  
  return (
    <>
      {children({
        isOpen,
        open: () => setIsOpen(true),
        close: () => setIsOpen(false),
        toggle: () => setIsOpen(prev => !prev)
      })}
      
      {isOpen && (
        <div className="modal-overlay" onClick={() => setIsOpen(false)}>
          <div className="modal-content" onClick={e => e.stopPropagation()}>
            {children.content?.({ close: () => setIsOpen(false) })}
          </div>
        </div>
      )}
    </>
  );
}

// Usage
function App() {
  return (
    <Modal>
      {({ isOpen, open, close }) => ({
        trigger: <button onClick={open}>Open Modal</button>,
        content: ({ close }) => (
          <div>
            <h2>Modal Content</h2>
            <button onClick={close}>Close</button>
          </div>
        )
      })}
    </Modal>
  );
}

When to Use Render Props

Good Use Cases

  • Sharing stateful logic between components
  • Conditional rendering based on complex state
  • Providing flexible rendering options
  • Creating reusable UI patterns

Alternatives to Consider

  • Custom Hooks: Often simpler for logic sharing
  • Component Composition: For simpler cases
  • Higher-Order Components: For cross-cutting concerns

Render Props vs Hooks

// Render Props approach
<MouseTracker>
  {({ x, y }) => <div>Mouse: {x}, {y}</div>}
</MouseTracker>

// Hooks approach (often preferred)
function Component() {
  const { x, y } = useMousePosition();
  return <div>Mouse: {x}, {y}</div>;
}

Render props provide a powerful way to share logic and create flexible, reusable components in React!