Back to Blog

Modern React Patterns Every Developer Should Know

6 min read
By AnhDan
Tech Guide
React
JavaScript
Design Patterns
Frontend
Modern React Patterns Every Developer Should Know

React has evolved significantly since its initial release, and with it, the patterns and best practices for building robust applications have also evolved. In this post, I'll share some of the most important React patterns that every developer should understand and use.

1. Compound Components Pattern

React Component Pattern

The compound components pattern allows you to create flexible and reusable components by composing them together. This pattern is particularly useful for complex UI components like modals, accordions, or form controls.

// Modal compound component
const Modal = ({ children, isOpen, onClose }) => {
  if (!isOpen) return null;
  
  return (
    <div className="modal-overlay" onClick={onClose}>
      <div className="modal-content" onClick={(e) => e.stopPropagation()}>
        {children}
      </div>
    </div>
  );
};

Modal.Header = ({ children }) => (
  <div className="modal-header">{children}</div>
);

Modal.Body = ({ children }) => (
  <div className="modal-body">{children}</div>
);

Modal.Footer = ({ children }) => (
  <div className="modal-footer">{children}</div>
);

// Usage
function App() {
  const [isOpen, setIsOpen] = useState(false);
  
  return (
    <Modal isOpen={isOpen} onClose={() => setIsOpen(false)}>
      <Modal.Header>
        <h2>Confirm Action</h2>
      </Modal.Header>
      <Modal.Body>
        <p>Are you sure you want to proceed?</p>
      </Modal.Body>
      <Modal.Footer>
        <button onClick={() => setIsOpen(false)}>Cancel</button>
        <button onClick={handleConfirm}>Confirm</button>
      </Modal.Footer>
    </Modal>
  );
}

2. Custom Hooks for Logic Reuse

Custom hooks are one of the most powerful features in React for sharing stateful logic between components.

// Custom hook for API calls
function useApi(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    const fetchData = async () => {
      try {
        setLoading(true);
        const response = await fetch(url);
        if (!response.ok) throw new Error('Failed to fetch');
        const result = await response.json();
        setData(result);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    };
    
    fetchData();
  }, [url]);
  
  return { data, loading, error };
}

// Custom hook for local storage
function useLocalStorage(key, initialValue) {
  const [storedValue, setStoredValue] = useState(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      console.error(error);
      return initialValue;
    }
  });
  
  const setValue = (value) => {
    try {
      setStoredValue(value);
      window.localStorage.setItem(key, JSON.stringify(value));
    } catch (error) {
      console.error(error);
    }
  };
  
  return [storedValue, setValue];
}

3. Context + Reducer Pattern

For more complex state management, combining Context API with useReducer provides a powerful alternative to external state management libraries.

// Actions
const ACTIONS = {
  ADD_TODO: 'ADD_TODO',
  TOGGLE_TODO: 'TOGGLE_TODO',
  DELETE_TODO: 'DELETE_TODO',
};

// Reducer
function todoReducer(state, action) {
  switch (action.type) {
    case ACTIONS.ADD_TODO:
      return {
        ...state,
        todos: [...state.todos, {
          id: Date.now(),
          text: action.payload,
          completed: false
        }]
      };
    case ACTIONS.TOGGLE_TODO:
      return {
        ...state,
        todos: state.todos.map(todo =>
          todo.id === action.payload
            ? { ...todo, completed: !todo.completed }
            : todo
        )
      };
    case ACTIONS.DELETE_TODO:
      return {
        ...state,
        todos: state.todos.filter(todo => todo.id !== action.payload)
      };
    default:
      return state;
  }
}

// Context
const TodoContext = createContext();

export function TodoProvider({ children }) {
  const [state, dispatch] = useReducer(todoReducer, {
    todos: []
  });
  
  const actions = {
    addTodo: (text) => dispatch({ type: ACTIONS.ADD_TODO, payload: text }),
    toggleTodo: (id) => dispatch({ type: ACTIONS.TOGGLE_TODO, payload: id }),
    deleteTodo: (id) => dispatch({ type: ACTIONS.DELETE_TODO, payload: id }),
  };
  
  return (
    <TodoContext.Provider value={{ state, actions }}>
      {children}
    </TodoContext.Provider>
  );
}

export function useTodos() {
  const context = useContext(TodoContext);
  if (!context) {
    throw new Error('useTodos must be used within TodoProvider');
  }
  return context;
}

4. Render Props Pattern

Although less common with hooks, render props are still useful for sharing code between components.

function DataFetcher({ url, render }) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    fetch(url)
      .then(response => response.json())
      .then(data => {
        setData(data);
        setLoading(false);
      })
      .catch(error => {
        setError(error);
        setLoading(false);
      });
  }, [url]);
  
  return render({ data, loading, error });
}

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

5. Higher-Order Components (HOCs)

HOCs are functions that take a component and return a new component with additional props or behavior.

function withAuth(WrappedComponent) {
  return function AuthenticatedComponent(props) {
    const [isAuthenticated, setIsAuthenticated] = useState(false);
    const [loading, setLoading] = useState(true);
    
    useEffect(() => {
      // Check authentication status
      checkAuthStatus()
        .then(setIsAuthenticated)
        .finally(() => setLoading(false));
    }, []);
    
    if (loading) {
      return <div>Loading...</div>;
    }
    
    if (!isAuthenticated) {
      return <div>Please log in to continue.</div>;
    }
    
    return <WrappedComponent {...props} />;
  };
}

// Usage
const ProtectedDashboard = withAuth(Dashboard);

6. Error Boundaries

Error boundaries catch JavaScript errors anywhere in the child component tree and display a fallback UI.

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false, error: null };
  }
  
  static getDerivedStateFromError(error) {
    return { hasError: true, error };
  }
  
  componentDidCatch(error, errorInfo) {
    console.error('Error caught by boundary:', error, errorInfo);
  }
  
  render() {
    if (this.state.hasError) {
      return this.props.fallback || (
        <div>
          <h2>Something went wrong.</h2>
          <p>{this.state.error?.message}</p>
        </div>
      );
    }
    
    return this.props.children;
  }
}

// Usage
function App() {
  return (
    <ErrorBoundary fallback={<ErrorFallback />}>
      <Header />
      <MainContent />
      <Footer />
    </ErrorBoundary>
  );
}

Performance Optimization Patterns

React.memo for Component Memoization

const ExpensiveComponent = React.memo(({ data, onUpdate }) => {
  // Expensive rendering logic
  return <div>{/* Rendered content */}</div>;
}, (prevProps, nextProps) => {
  // Custom comparison function
  return prevProps.data.id === nextProps.data.id;
});

useMemo and useCallback

function OptimizedComponent({ items, filter }) {
  // Memoize expensive calculations
  const filteredItems = useMemo(() => {
    return items.filter(item => item.category === filter);
  }, [items, filter]);
  
  // Memoize callback functions
  const handleItemClick = useCallback((id) => {
    // Handle click logic
  }, []);
  
  return (
    <div>
      {filteredItems.map(item => (
        <Item 
          key={item.id} 
          item={item} 
          onClick={handleItemClick} 
        />
      ))}
    </div>
  );
}

Best Practices Summary

  1. Keep components small and focused: Each component should have a single responsibility
  2. Use custom hooks for reusable logic: Extract stateful logic into custom hooks
  3. Leverage composition over inheritance: Use composition patterns for flexibility
  4. Optimize performance judiciously: Don't optimize prematurely, but know the tools
  5. Handle errors gracefully: Use error boundaries and proper error handling
  6. Write testable code: Structure your components to be easily testable

Conclusion

These patterns represent the evolution of React development practices. By mastering them, you'll be able to build more maintainable, performant, and scalable React applications.

Remember, patterns are tools in your toolbox - use them when they solve real problems, not just because they exist. The key is understanding when and why to apply each pattern.

Happy coding!