React Design Patterns

React Design Patterns

October 1, 2024

reactpatternsdesign

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:

1// ❌ Too many responsibilities!
2function BigComponent() {
3  // Responsible for multiple unrelated states
4  const [data, setData] = useState();
5  const [isModalOpen, setIsModalOpen] = useState(false);
6
7  // Responsible for fetching data
8  useEffect(() => {
9    fetch('/api/data')
10      .then(response => response.json())
11      .then(data => setData(data));
12  }, []);
13
14  // Responsible for implementing sending analytics events
15  useEffect(() => {
16    sendAnalyticsEvent('page_view', { page: 'big_component' });
17  }, []);
18
19  // Responsible for toggling modal
20  function toggleModal() {
21    setIsModalOpen(prev => !prev);
22  }
23
24  // ... other code
25}
26

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.

1// ✅ Single responsibility: managing data
2export function useFetchData() {
3  const [data, setData] = useState();
4
5  useEffect(() => {
6    fetch('/api/data')
7      .then(response => response.json())
8      .then(data => setData(data));
9  }, []);
10
11  return data;
12}
13

Or even better, use react-query.

1// ✅ Single responsibility: managing data through react query
2export function useFetchData() {
3  return useQuery({
4    queryKey: ['data'],
5    queryFn: () => fetch('/api/data'),
6  });
7}
8

Then create usePageAnalytics.ts. This hook will receive an event through props and send it.

1type Event = {
2  page: string;
3};
4
5// ✅ Single responsibility: managing analytics
6export function usePageAnalytics(event: Event) {
7  useEffect(() => {
8    sendAnalyticsEvent('page_view', event);
9  }, []);
10}
11

Finally create Modal.tsx. This component will receive children as props, and manage its own isModalOpen state.

1type ModalProps = {
2  children: React.ReactNode;
3};
4
5// ✅ Single responsibility: managing modals
6export function Modal({ children }: ModalProps) {
7  const [isModalOpen, setIsModalOpen] = useState(false);
8
9  function toggleModal() {
10    setIsModalOpen(prev => !prev);
11  }
12
13  return (
14    <>
15      <button onPress={toggleModal}>Open</button>
16      {isModalOpen && children}
17    </>
18  );
19}
20

With this, BigComponent just needs to import and put everything together. It is now small, easy to manage, and highly scalable.

1import { useFetchData } from './useFetchData';
2import { useAnalytics } from './useAnalytics';
3import { Modal } from './Modal';
4
5// ✅ Single responsibility: put everything together
6function BigComponent() {
7  const data = useFetchData();
8
9  useAnalytics();
10
11  return <Modal>{/* ... other code */}</Modal>;
12}
13

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.

1// Container component responsible for logic
2function ContainerComponent() {
3  const [items, setItems] = useState([]);
4  const [filters, setFilters] = useState({});
5
6  useEffect(() => {
7    const filteredItems = filterItems(items, filters);
8  }, [filters]);
9
10  function handleFilters(newFilters) {
11    setFilters(newFilters);
12  }
13
14  // ... other business logic code
15
16  return <PresentationComponent items={items} />;
17}
18
19// Presentation component responsible for UI
20function PresentationComponent({ items }) {
21  return (
22    <>
23      {/* ... other UI code */}
24      {items.map(item => (
25        <ItemCard key={item.id} item={item} />
26      ))}
27      {/* ... other UI code */}
28    </>
29  );
30}
31

3. Compound Components Pattern

Group components meant to be used together into a compound component with the React Context API.

1import { createContext, useState } from 'react';
2
3const ToggleContext = createContext();
4
5// Main component exported for use in project
6export default function Toggle({ children }) {
7  const [on, setOn] = useState(false);
8
9  function toggle() {
10    setOn(!on);
11  }
12
13  return (
14    <ToggleContext.Provider value={{ on, toggle }}>
15      {children}
16    </ToggleContext.Provider>
17  );
18}
19
20// Compound component attached to main component
21Toggle.On = function ToggleOn({ children }) {
22  const { on } = useContext(ToggleContext);
23  return on ? children : null;
24};
25
26// Compound component attached to main component
27Toggle.Off = function ToggleOff({ children }) {
28  const { on } = useContext(ToggleContext);
29  return on ? null : children;
30};
31
32// Compound component attached to main component
33Toggle.Button = function ToggleButton(props) {
34  const { on, toggle } = useContext(ToggleContext);
35  return <button onClick={toggle} {...props} />;
36};
37

This component can now be used anywhere with great flexibility. Place sub-components in any order, or only use a subset of them:

1import Toggle from '@/components/Toggle';
2
3// Example use case with all components
4function App() {
5  return (
6    <Toggle>
7      <Toggle.On>The button is on</Toggle.On>
8      <Toggle.Off>The button is off</Toggle.Off>
9      <Toggle.Button>Toggle</Toggle.Button>
10    </Toggle>
11  );
12}
13
14// Example use case with different order
15function App() {
16  return (
17    <Toggle>
18      <Toggle.Button>Toggle</Toggle.Button>
19      <Toggle.Off>The button is off</Toggle.Off>
20      <Toggle.On>The button is on</Toggle.On>
21    </Toggle>
22  );
23}
24
25// Example use case with partial components
26function App() {
27  return (
28    <Toggle>
29      <Toggle.Button>Toggle</Toggle.Button>
30    </Toggle>
31  );
32}
33

4. Nested Prop Forwarding

When a flexible component uses another, allow props to be forwarded to the nested component.

1// Receives props as `...rest`
2function Text({ children, ...rest }) {
3  return (
4    <span className="text-primary" {...rest}>
5      {children}
6    </span>
7  );
8}
9
10// Button component uses `Text` component for its text
11function Button({ children, textProps, ...rest }) {
12  return (
13    <button {...rest}>
14      {/* ✅ `textProps` are forwarded */}
15      <Text {...textProps}>{children}</Text>
16    </button>
17  );
18}
19

Example usage:

1function App() {
2  return (
3    <Button textProps={{ className: 'text-red-500' }}>
4      Button with red text
5    </Button>
6  );
7}
8

5. Children Components Pattern

To improve performance and prevent unnecessary re-renders, lift up components and pass them as children instead.

1function Component() {
2  const [count, setCount] = useState(0);
3
4  return (
5    <div>
6      {count}
7      {/* ❌ Expensive component will re-render unnecessarily everytime count changes */}
8      <ExpensiveComponent />
9    </div>
10  );
11}
12

Moving ExpensiveComponent up and passing it as children will prevent it re-rendering

1// Component
2function Component({ children }) {
3  const [count, setCount] = useState(0);
4
5  // ✅ Children don't re-render when state changes
6  return <Component>{children}</Component>;
7}
8
9// App
10function App() {
11  return (
12    <Component>
13      {/* ✅ Expensive component will not re-render when Component does */}
14      <ExpensiveComponent />
15    </Component>
16  );
17}
18

6. Custom Hooks

To keep code clean and re-usable, extract related functionality into a custom hook that can be shared.

1// ❌ All code related to `items` is directly in component.
2function Component() {
3  const [items, setItems] = useState([]);
4  const [filters, setFilters] = useState({});
5
6  useEffect(() => {
7    const filteredItems = filterItems(items, filters);
8  }, [filters]);
9
10  function handleFilters(newFilters) {
11    setFilters(newFilters);
12  }
13
14  // ... other code
15}
16

You can create useFilteredItems.ts and put all of the functionality there.

1// ✅ All code related to `items` is in custom re-usable hook
2export function useFilteredItems() {
3  const [items, setItems] = useState([]);
4  const [filters, setFilters] = useState({});
5
6  useEffect(() => {
7    const filteredItems = filterItems(items, filters);
8  }, [filters]);
9
10  function handleFilters(newFilters) {
11    setFilters(newFilters);
12  }
13
14  return {
15    items,
16    filters,
17    handleFilters,
18  };
19}
20

Then in Component you can use the hook instead.

1// ✅ Component is cleaner, and can share functionality of filtered items
2function Component() {
3  const { items, filters, handleFilters } = useFilteredItems();
4
5  // ... other code
6}
7

7. Higher Order Components (HOC)

Sometimes, it's better to create a higher order component (HOC) to share re-usable functionality.

1function Button(props) {
2  // ❌ Styles object is duplicated
3  const style = { padding: 8, margin: 12 };
4  return <button style={style} {...props} />;
5}
6
7function TextInput(props) {
8  // ❌ Styles object is duplicated
9  const style = { padding: 8, margin: 12 };
10  return <input type="text" style={style} {...props} />;
11}
12

With HOCs, you can create a wrapper component that takes a component with its props, and enhances it.

1// ✅ Higher order component to implement styles
2function withStyles(Component) {
3  return props => {
4    const style = { padding: 8, margin: 12 };
5
6    // Merges component props with custom style object
7    return <Component style={style} {...props} />;
8  };
9}
10
11// Inner components receive style through props
12function Button({ style, ...props }) {
13  return <button style={style} {...props} />;
14}
15function TextInput({ style, ...props }) {
16  return <input type="text" style={style} {...props} />;
17}
18
19// ✅ Wrap exports with HOC
20export default withStyles(Button);
21export default withStyles(Text);
22

8. Variant Props

If you have components that are shared across the app, create variant props to easily customize them using preset values.

1type ButtonProps = ComponentProps<'button'> & {
2  variant?: 'primary' | 'secondary';
3  size?: 'sm' | 'md' | 'lg';
4};
5
6function Button({ variant = 'primary', size = 'md', ...rest }: ButtonProps) {
7  // ✅ Styles derived based on variant and size
8  const style = {
9    ...styles.variant[variant],
10    ...styles.size[size],
11  };
12
13  return <button style={style} {...rest} />;
14}
15
16// ✅ Custom object with clearly defined styles for every variant/size
17const styles = {
18  variant: {
19    primary: {
20      backgroundColor: 'blue',
21    },
22    secondary: {
23      backgroundColor: 'gray',
24    },
25  },
26  size: {
27    sm: {
28      minHeight: 10,
29    },
30    md: {
31      minHeight: 12,
32    },
33    lg: {
34      minHeight: 16,
35    },
36  },
37};
38

Example usage:

1function App() {
2  return (
3    <div>
4      <Button>Primary Button</Button>
5      <Button variant="secondary" size="sm">
6        Secondary Button
7      </Button>
8    </div>
9  );
10}
11

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.

1type Props = {
2  componentRef: React.RefObject<{ reset: () => void }>;
3};
4
5function Component({ componentRef }: Props) {
6  const [count, setCount] = useState(0);
7
8  // ✅ Exposes custom reset function to parent through ref to change state
9  useImperativeHandle(componentRef, () => ({
10    reset: () => {
11      setCount(0);
12    },
13  }));
14
15  return (
16    <div>
17      {count}
18      <button onClick={() => setCount(count + 1)}>Increment</button>
19    </div>
20  );
21}
22

And to use it, simply create a ref in the same component where it is rendered.

1function App() {
2  const componentRef = useRef(null);
3
4  return (
5    <>
6      <Component componentRef={componentRef} />
7
8      {/* ✅ Using the ref we can reset the inner state of Component */}
9      <button onClick={() => componentRef.current?.reset()}>Reset</button>
10    </>
11  );
12}
13

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.

1function Component1() {
2  // ❌ User is fetched in multiple components
3  const { data: user } = useFetchUser();
4
5  // ❌ Unnecessary duplicate check for undefined user
6  if (!user) {
7    return <div>Loading...</div>;
8  }
9
10  // ... return JSX
11}
12
13function Component2() {
14  // ❌ User is fetched in multiple components
15  const { data: user } = useFetchUser();
16
17  // ❌ Unnecessary duplicate check for undefined user
18  if (!user) {
19    return <div>Loading...</div>;
20  }
21
22  // ... return JSX
23}
24

With a Provider, we can have all of that functionality inside a single component.

1const UserContext = createContext(undefined);
2
3function UserProvider({ children }) {
4  // ✅ User fetch is done in provider
5  const { data: user } = useFetchUser();
6
7  // ✅ User check is done in provider
8  if (!user) {
9    return <div>Loading...</div>;
10  }
11
12  return (
13    {/* ✅ User is always going to be available from here on */}
14    <UserContext.Provider value={{ user }}>{children}</UserContext.Provider>
15  );
16}
17
18// Custom hook to easily access context
19export function useUser() {
20  const context = useContext(UserContext);
21
22  if (!context) {
23    throw new Error('useUser must be used within a UserProvider.');
24  }
25
26  return context;
27}
28

After wrapping the entire app with it, you can use the shared functionality everywhere.

1function App() {
2  return (
3    {/* ✅ Wrap every component with the provider */}
4    <UserProvider>
5      <Component1 />
6      <Component2 />
7    </UserProvider>
8  );
9}
10function Component1() {
11  // ✅ User is accessed from provider
12  const { user } = useUser();
13
14  // ✅ Can directly use user without checking if it is there
15}
16
17function Component2() {
18  // ✅ User is accessed from provider
19  const { user } = useUser();
20
21  // ✅ Can directly use user without checking if it is there
22}
23

Related Posts

Loading States vs Suspense Fallback in React

Read more