This is a collection of the most important design patterns to use in React. Made by Cosden Solutions.
1. Single Responsibility Principle
Your components should have only one responsibility. They should only do "one thing" and delegate everything else to other components. Here's an example of a component that has too many responsibilities:
// ❌ Too many responsibilities! function BigComponent() { // Responsible for multiple unrelated states const [data, setData] = useState(); const [isModalOpen, setIsModalOpen] = useState(false); // Responsible for fetching data useEffect(() => { fetch('/api/data') .then(response => response.json()) .then(data => setData(data)); }, []); // Responsible for implementing sending analytics events useEffect(() => { sendAnalyticsEvent('page_view', { page: 'big_component' }); }, []); // Responsible for toggling modal function toggleModal() { setIsModalOpen(prev => !prev); } // ... other code }
Instead, create multiple components/hooks each with a single responsibility.
First, create useFetchData.ts
. This hook will hold the data
state and manage fetching and updating it.
// ✅ Single responsibility: managing data export function useFetchData() { const [data, setData] = useState(); useEffect(() => { fetch('/api/data') .then(response => response.json()) .then(data => setData(data)); }, []); return data; }
Or even better, use react-query
.
// ✅ Single responsibility: managing data through react query export function useFetchData() { return useQuery({ queryKey: ['data'], queryFn: () => fetch('/api/data'), }); }
Then create usePageAnalytics.ts
. This hook will receive an event
through props and send it.
type Event = { page: string; }; // ✅ Single responsibility: managing analytics export function usePageAnalytics(event: Event) { useEffect(() => { sendAnalyticsEvent('page_view', event); }, []); }
Finally create Modal.tsx
. This component will receive children
as props, and manage its own isModalOpen
state.
type ModalProps = { children: React.ReactNode; }; // ✅ Single responsibility: managing modals export function Modal({ children }: ModalProps) { const [isModalOpen, setIsModalOpen] = useState(false); function toggleModal() { setIsModalOpen(prev => !prev); } return ( <> <button onPress={toggleModal}>Open</button> {isModalOpen && children} </> ); }
With this, BigComponent
just needs to import and put everything together. It is now small, easy to manage, and highly scalable.
import { useFetchData } from './useFetchData'; import { useAnalytics } from './useAnalytics'; import { Modal } from './Modal'; // ✅ Single responsibility: put everything together function BigComponent() { const data = useFetchData(); useAnalytics(); return <Modal>{/* ... other code */}</Modal>; }
2. Container and Presentation Components
To keep code organized, you can split your components into a container and a presentation component. The container component holds all the logic, and the presentation component renders the UI.
// Container component responsible for logic function ContainerComponent() { const [items, setItems] = useState([]); const [filters, setFilters] = useState({}); useEffect(() => { const filteredItems = filterItems(items, filters); }, [filters]); function handleFilters(newFilters) { setFilters(newFilters); } // ... other business logic code return <PresentationComponent items={items} />; } // Presentation component responsible for UI function PresentationComponent({ items }) { return ( <> {/* ... other UI code */} {items.map(item => ( <ItemCard key={item.id} item={item} /> ))} {/* ... other UI code */} </> ); }
3. Compound Components Pattern
Group components meant to be used together into a compound component with the React Context API.
import { createContext, useState } from 'react'; const ToggleContext = createContext(); // Main component exported for use in project export default function Toggle({ children }) { const [on, setOn] = useState(false); function toggle() { setOn(!on); } return ( <ToggleContext.Provider value={{ on, toggle }}> {children} </ToggleContext.Provider> ); } // Compound component attached to main component Toggle.On = function ToggleOn({ children }) { const { on } = useContext(ToggleContext); return on ? children : null; }; // Compound component attached to main component Toggle.Off = function ToggleOff({ children }) { const { on } = useContext(ToggleContext); return on ? null : children; }; // Compound component attached to main component Toggle.Button = function ToggleButton(props) { const { on, toggle } = useContext(ToggleContext); return <button onClick={toggle} {...props} />; };
This component can now be used anywhere with great flexibility. Place sub-components in any order, or only use a subset of them:
import Toggle from '@/components/Toggle'; // Example use case with all components function App() { return ( <Toggle> <Toggle.On>The button is on</Toggle.On> <Toggle.Off>The button is off</Toggle.Off> <Toggle.Button>Toggle</Toggle.Button> </Toggle> ); } // Example use case with different order function App() { return ( <Toggle> <Toggle.Button>Toggle</Toggle.Button> <Toggle.Off>The button is off</Toggle.Off> <Toggle.On>The button is on</Toggle.On> </Toggle> ); } // Example use case with partial components function App() { return ( <Toggle> <Toggle.Button>Toggle</Toggle.Button> </Toggle> ); }
4. Nested Prop Forwarding
When a flexible component uses another, allow props to be forwarded to the nested component.
// Receives props as `...rest` function Text({ children, ...rest }) { return ( <span className="text-primary" {...rest}> {children} </span> ); } // Button component uses `Text` component for its text function Button({ children, textProps, ...rest }) { return ( <button {...rest}> {/* ✅ `textProps` are forwarded */} <Text {...textProps}>{children}</Text> </button> ); }
Example usage:
function App() { return ( <Button textProps={{ className: 'text-red-500' }}> Button with red text </Button> ); }
5. Children Components Pattern
To improve performance and prevent unnecessary re-renders, lift up components and pass them as children instead.
function Component() { const [count, setCount] = useState(0); return ( <div> {count} {/* ❌ Expensive component will re-render unnecessarily everytime count changes */} <ExpensiveComponent /> </div> ); }
Moving ExpensiveComponent
up and passing it as children will prevent it re-rendering
// Component function Component({ children }) { const [count, setCount] = useState(0); // ✅ Children don't re-render when state changes return <Component>{children}</Component>; } // App function App() { return ( <Component> {/* ✅ Expensive component will not re-render when Component does */} <ExpensiveComponent /> </Component> ); }
6. Custom Hooks
To keep code clean and re-usable, extract related functionality into a custom hook that can be shared.
// ❌ All code related to `items` is directly in component. function Component() { const [items, setItems] = useState([]); const [filters, setFilters] = useState({}); useEffect(() => { const filteredItems = filterItems(items, filters); }, [filters]); function handleFilters(newFilters) { setFilters(newFilters); } // ... other code }
You can create useFilteredItems.ts
and put all of the functionality there.
// ✅ All code related to `items` is in custom re-usable hook export function useFilteredItems() { const [items, setItems] = useState([]); const [filters, setFilters] = useState({}); useEffect(() => { const filteredItems = filterItems(items, filters); }, [filters]); function handleFilters(newFilters) { setFilters(newFilters); } return { items, filters, handleFilters, }; }
Then in Component
you can use the hook instead.
// ✅ Component is cleaner, and can share functionality of filtered items function Component() { const { items, filters, handleFilters } = useFilteredItems(); // ... other code }
7. Higher Order Components (HOC)
Sometimes, it's better to create a higher order component (HOC) to share re-usable functionality.
function Button(props) { // ❌ Styles object is duplicated const style = { padding: 8, margin: 12 }; return <button style={style} {...props} />; } function TextInput(props) { // ❌ Styles object is duplicated const style = { padding: 8, margin: 12 }; return <input type="text" style={style} {...props} />; }
With HOCs, you can create a wrapper component that takes a component with its props, and enhances it.
// ✅ Higher order component to implement styles function withStyles(Component) { return props => { const style = { padding: 8, margin: 12 }; // Merges component props with custom style object return <Component style={style} {...props} />; }; } // Inner components receive style through props function Button({ style, ...props }) { return <button style={style} {...props} />; } function TextInput({ style, ...props }) { return <input type="text" style={style} {...props} />; } // ✅ Wrap exports with HOC export default withStyles(Button); export default withStyles(Text);
8. Variant Props
If you have components that are shared across the app, create variant props to easily customize them using preset values.
type ButtonProps = ComponentProps<'button'> & { variant?: 'primary' | 'secondary'; size?: 'sm' | 'md' | 'lg'; }; function Button({ variant = 'primary', size = 'md', ...rest }: ButtonProps) { // ✅ Styles derived based on variant and size const style = { ...styles.variant[variant], ...styles.size[size], }; return <button style={style} {...rest} />; } // ✅ Custom object with clearly defined styles for every variant/size const styles = { variant: { primary: { backgroundColor: 'blue', }, secondary: { backgroundColor: 'gray', }, }, size: { sm: { minHeight: 10, }, md: { minHeight: 12, }, lg: { minHeight: 16, }, }, };
Example usage:
function App() { return ( <div> <Button>Primary Button</Button> <Button variant="secondary" size="sm"> Secondary Button </Button> </div> ); }
9. Expose functionality through ref
Sometimes it can be useful to export functionality from one child component to a parent through a ref. This can be done using the useImperativeHandle
hook.
type Props = { componentRef: React.RefObject<{ reset: () => void }>; }; function Component({ componentRef }: Props) { const [count, setCount] = useState(0); // ✅ Exposes custom reset function to parent through ref to change state useImperativeHandle(componentRef, () => ({ reset: () => { setCount(0); }, })); return ( <div> {count} <button onClick={() => setCount(count + 1)}>Increment</button> </div> ); }
And to use it, simply create a ref in the same component where it is rendered.
function App() { const componentRef = useRef(null); return ( <> <Component componentRef={componentRef} /> {/* ✅ Using the ref we can reset the inner state of Component */} <button onClick={() => componentRef.current?.reset()}>Reset</button> </> ); }
10. Use providers for frequently used data
If you have data that is shared across multiple components, consider putting it in a provider using the Context API.
function Component1() { // ❌ User is fetched in multiple components const { data: user } = useFetchUser(); // ❌ Unnecessary duplicate check for undefined user if (!user) { return <div>Loading...</div>; } // ... return JSX } function Component2() { // ❌ User is fetched in multiple components const { data: user } = useFetchUser(); // ❌ Unnecessary duplicate check for undefined user if (!user) { return <div>Loading...</div>; } // ... return JSX }
With a Provider, we can have all of that functionality inside a single component.
const UserContext = createContext(undefined); function UserProvider({ children }) { // ✅ User fetch is done in provider const { data: user } = useFetchUser(); // ✅ User check is done in provider if (!user) { return <div>Loading...</div>; } return ( {/* ✅ User is always going to be available from here on */} <UserContext.Provider value={{ user }}>{children}</UserContext.Provider> ); } // Custom hook to easily access context export function useUser() { const context = useContext(UserContext); if (!context) { throw new Error('useUser must be used within a UserProvider.'); } return context; }
After wrapping the entire app with it, you can use the shared functionality everywhere.
function App() { return ( {/* ✅ Wrap every component with the provider */} <UserProvider> <Component1 /> <Component2 /> </UserProvider> ); } function Component1() { // ✅ User is accessed from provider const { user } = useUser(); // ✅ Can directly use user without checking if it is there } function Component2() { // ✅ User is accessed from provider const { user } = useUser(); // ✅ Can directly use user without checking if it is there }