The Most Common React Design Patterns
Many years ago I played League of Legends for about 2-3 years.
If you have ever played it as well, you know that there is a champion called Caitlyn. Caitlyn is straightforward to play but hard to master. You need to use her abilities aggressively early in the game otherwise mid game you will have a hard time.
I would compare coding in React with playing Caitlyn. It's simple, every developer who knows a bit of Javascript can write some components, connect them and voila - there is your web app.
React makes sharing JSX straightforward, but reusing state and lifecycle logic is more complex. Traditional object-oriented approaches like inheritance don't align with React's component philosophy. Some of the key problems are:
- Class methods aren't easily transferable between components
- Inheritance-based solutions are anti-patterns in React
- State management requires more nuanced strategies
The magic of great React development lies in breaking down complex interfaces into simple, reusable components. By creating modular code structures, you can build flexible and readable applications that are easier to debug and expand.
Here are some of the most common design patterns you can use to achieve that in React apps.
Higher-Order-Components (HOC)
Higher-order components are function wrappers that enhance components by adding shared functionality without repeating code.
Key characteristics are reusable logic injection, no code duplication, and extended component capabilities.
Common situations where HOCs are used are authentication checks, logging mechanisms, performance trackers, analytics integration, and others.
The important thing about HOC is to know when to apply them. These are some valid reasons:
- Need to share functionality across 2 or more components
- Want to add cross-shared business logic without modifying the original component
- Seeking modular code expansion
Here is a code example:
import React, { useState, useEffect } from 'react';
function withFightMoveTracker<P extends object>(
WrappedComponent: React.ComponentType<P>
) {
return function FightMoveTrackerComponent(props: P) {
const [moves, setMoves] = useState<string[]>([]);
const trackMove = (moveName: string) => {
setMoves(prevMoves => [...prevMoves, moveName]);
};
useEffect(() => {
console.log('Fight Moves History:', moves);
}, [moves]);
return (
<WrappedComponent
{...props}
trackMove={trackMove}
moveHistory={moves}
/>
);
};
}
interface PokemonFighterProps {
name: string;
trackMove: (move: string) => void;
moveHistory: string[];
}
const PokemonFighter: React.FC<PokemonFighterProps> = ({
name,
trackMove,
moveHistory
}) => {
const moves = [
'Thunderbolt',
'Quick Attack',
'Iron Tail',
'Electro Ball'
];
return (
<div>
<h2>{name} Fighter</h2>
<div>
{moves.map(move => (
<button
key={move}
onClick={() => trackMove(move)}
>
{move}
</button>
))}
</div>
<div>
<h3>Move History:</h3>
<ul>
{moveHistory.map((move, index) => (
<li key={index}>{move}</li>
))}
</ul>
</div>
</div>
);
};
const TrackedPokemonFighter = withFightMoveTracker(PokemonFighter);
export default TrackedPokemonFighter;
Some of the cons when using HOCs are props collision and ugly code patterns like this, if we need multiple HOCs:
export default aHoC(bHoc(cHoc(PokemonComponent)))
Also, remember that you can't use the same HOC twice. So this won't work:
export default withLogger(withLogger(PokemonComponent))
Another problem is prop indirection:
function PokemonComponent({ name, trackMove, setMoves, moveHistory, date, image, types, abilities }) {
// ...
}
export default aHoC(bHoc(cHoc(PokemonComponent)))
In the example above, you can't tell which prop is coming from which HOC.
These flaws are not critical, I just noticed that some annoy me when working with HOCs.
Render Props
Render Props pattern offers an alternative to HOCs in React. It shares a similar goal of creating a reusable state and functionality.
The difference is that Render Props passes functionality through props instead of wrapping components. This way, more direct control is provided over component rendering and we avoid the complexities of component wrapping which is typical in HOCs.
The core mechanism is based on a function prop to inject dynamic content. This enables controlled state and behavior sharing and maintains component composition flexibility.
Here is an example:
import React, { useState } from 'react';
interface PokemonBattleProps {
render: (
isBattling: boolean,
toggleBattle: () => void,
opponent: string
) => React.ReactNode;
}
const PokemonBattleToggle: React.FC<PokemonBattleProps> = ({ render }) => {
const [isBattling, setIsBattling] = useState(false);
const [opponent, setOpponent] = useState('Charizard');
const toggleBattle = () => {
setIsBattling(prevState => !prevState);
};
const opponents = ['Charizard', 'Gengar', 'Blastoise', 'Raichu'];
const changeOpponent = () => {
const currentIndex = opponents.indexOf(opponent);
const nextIndex = (currentIndex + 1) % opponents.length;
setOpponent(opponents[nextIndex]);
};
return <>{render(isBattling, toggleBattle, opponent)}</>;
};
function PokemonTrainerPage() {
return (
<div>
<h1>Pokemon Trainer Arena</h1>
<PokemonBattleToggle
render={(isBattling, toggleBattle, opponent) => (
<div>
<button onClick={toggleBattle}>
Battle Status: {isBattling ? 'Fighting' : 'Ready'}
</button>
{isBattling && (
<div>
<p>Pikachu is battling!</p>
<p>Opponent: {opponent}</p>
</div>
)}
</div>
)}
/>
<footer>Pokemon League Championship</footer>
</div>
);
}
export default PokemonTrainerPage;
Almost all those flaws we had with HoCs go away with Render Props. Prop collisions won't happen when we pass our props to the component. We can also render a component twice.
One flaw is that the Render Props pattern looks kinda weird.
HOCs offer a clean, prop injection through simple function calls, while Render Props introduce more complex, nested JSX structures that can look less elegant.
Container-Presenter pattern
The container-presenter pattern is a powerful strategy that cleanly separates a component's responsibilities into two distinct roles:
- Containers
- Presenters
Containers are the logic managers. They handle the "how" of component functionality, manage state and data retrieval, and control user interactions. In short, they act as the brain behind the component's operations.
On the other hand, the presenters are the painters. They focus on the "what" - the visual representation and rendering UI elements. Their job is to receive data and display it elegantly, so they are purely concerned with presentation logic
Some of the benefits are dramatically simplified component structure. With this you also get improved maintainability: each part has a clear, focused purpose.
Here are some code examples:
import React, { useState, useEffect } from 'react';
export const PokemonContainer: React.FC = () => {
const [pokemon, setPokemon] = useState<any>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchPokemon = async () => {
try {
const response = await fetch('https://pokeapi.co/api/v2/pokemon/pikachu');
if (!response.ok) {
throw new Error('Failed to fetch Pokemon');
}
const data = await response.json();
setPokemon(data);
setLoading(false);
} catch (err) {
setError(err instanceof Error ? err.message : 'An unknown error occurred');
setLoading(false);
}
};
fetchPokemon();
}, []);
if (loading) return <PokemonPresenter status="loading" />;
if (error) return <PokemonPresenter status="error" errorMessage={error} />;
if (!pokemon) return null;
return <PokemonPresenter
status="success"
name={pokemon.name}
imageUrl={pokemon.sprites.front_default}
types={pokemon.types.map((type: any) => type.type.name)}
abilities={pokemon.abilities.map((ability: any) => ability.ability.name)}
/>;
};
interface PokemonPresenterProps {
status: 'loading' | 'error' | 'success';
name?: string;
imageUrl?: string;
types?: string[];
abilities?: string[];
errorMessage?: string;
}
export const PokemonPresenter: React.FC<PokemonPresenterProps> = ({
status,
name,
imageUrl,
types,
abilities,
errorMessage
}) => {
if (status === 'loading') {
return <div>Loading Pokemon...</div>;
}
if (status === 'error') {
return <div>Error: {errorMessage}</div>;
}
return (
<div className="pokemon-card">
<h2>{name}</h2>
<img src={imageUrl} alt={name} />
<div>
<h3>Types:</h3>
<ul>
{types?.map(type => <li key={type}>{type}</li>)}
</ul>
</div>
<div>
<h3>Abilities:</h3>
<ul>
{abilities?.map(ability => <li key={ability}>{ability}</li>)}
</ul>
</div>
</div>
);
};
Compound Components
Compound components are a design pattern in UI development that lets you create connected, adaptable interface elements by logically grouping related components.
This technique is commonly implemented in popular React libraries like React Bootstrap, Material UI, Radix UI, and React Router to build complex, modular user interfaces.
import React, { createContext, useContext, useState } from 'react';
type PokemonTeamContextType = {
selectedTypes: string[];
toggleType: (type: string) => void;
};
const PokemonTeamContext = createContext<PokemonTeamContextType | undefined>(undefined);
const PokemonTeamBuilder: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [selectedTypes, setSelectedTypes] = useState<string[]>([]);
const toggleType = (type: string) => {
setSelectedTypes(current =>
current.includes(type)
? current.filter(t => t !== type)
: [...current, type]
);
};
return (
<PokemonTeamContext.Provider value={{ selectedTypes, toggleType }}>
<div className="pokemon-team-builder">{children}</div>
</PokemonTeamContext.Provider>
);
};
const TypeSelector: React.FC<{ type: string }> = ({ type }) => {
const context = useContext(PokemonTeamContext);
if (!context) throw new Error('TypeSelector must be used within PokemonTeamBuilder');
const { selectedTypes, toggleType } = context;
const isSelected = selectedTypes.includes(type);
return (
<div
className={`type-selector ${isSelected ? 'selected' : ''}`}
onClick={() => toggleType(type)}
>
{type}
</div>
);
};
const TeamSummary: React.FC = () => {
const context = useContext(PokemonTeamContext);
if (!context) throw new Error('TeamSummary must be used within PokemonTeamBuilder');
const { selectedTypes } = context;
return (
<div className="team-summary">
Selected Types: {selectedTypes.join(', ')}
</div>
);
};
PokemonTeamBuilder.TypeSelector = TypeSelector;
PokemonTeamBuilder.TeamSummary = TeamSummary;
export default PokemonTeamBuilder;
// Usage Example
const App = () => (
<PokemonTeamBuilder>
<PokemonTeamBuilder.TypeSelector type="Fire" />
<PokemonTeamBuilder.TypeSelector type="Water" />
<PokemonTeamBuilder.TypeSelector type="Grass" />
<PokemonTeamBuilder.TypeSelector type="Electric" />
<PokemonTeamBuilder.TeamSummary />
</PokemonTeamBuilder>
);
When using this pattern you feel that you are playing with Lego blocks.
Custom Hooks
Hooks represent a 180-degree shift in component logic, not just a replacement for Higher-Order Components (HOCs) and Render Props. Unlike previous patterns, Hooks provides a more elegant solution in state management and code reuse.
The core challenges with HOCs and Render Props were about component composition and value accessibility.
Hooks solves these issues by allowing direct state and logic sharing within components. They eliminate the need for complex wrapper components or nested render functions.
This is not an explanation of how hooks work. I'll assume you know enough about hooks to understand the difference to HoCs and Render Props. If not, here is an in-depth article about hooks.
Here is an example:
import { useState, useEffect } from 'react';
// Custom hook to fetch and manage Pokemon data
export const usePokemon = (pokemonName: string) => {
const [pokemon, setPokemon] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchPokemon = async () => {
try {
setLoading(true);
const response = await fetch(`https://pokeapi.co/api/v2/pokemon/${pokemonName}`);
if (!response.ok) throw new Error('Pokemon not found');
const data = await response.json();
setPokemon({
name: data.name,
type: data.types[0].type.name,
image: data.sprites.front_default,
stats: {
hp: data.stats[0].base_stat,
attack: data.stats[1].base_stat,
defense: data.stats[2].base_stat
}
});
} catch (err) {
setError(err instanceof Error ? err.message : 'An unknown error occurred');
} finally {
setLoading(false);
}
};
fetchPokemon();
}, [pokemonName]);
return { pokemon, loading, error };
};
// Custom hook for managing Pokemon team
export const usePokemonTeam = (initialTeam: string[] = []) => {
const [team, setTeam] = useState<string[]>(initialTeam);
const addPokemon = (pokemonName: string) => {
setTeam(currentTeam =>
currentTeam.length < 6 && !currentTeam.includes(pokemonName)
? [...currentTeam, pokemonName]
: currentTeam
);
};
const removePokemon = (pokemonName: string) => {
setTeam(currentTeam =>
currentTeam.filter(pokemon => pokemon !== pokemonName)
);
};
return { team, addPokemon, removePokemon };
};
// Usage in component
const PokemonTrainer = () => {
const { pokemon, loading, error } = usePokemon('pikachu');
const { team, addPokemon, removePokemon } = usePokemonTeam();
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return (
<div>
{pokemon && (
<div>
<h2>{pokemon.name}</h2>
<button onClick={() => addPokemon(pokemon.name)}>
Add to Team
</button>
</div>
)}
<div>
Team: {team.join(', ')}
</div>
</div>
);
};
Here are some advantages of hooks over HoCs:
- Eliminates variable name conflicts
- Removes unnecessary prop passing complexity
- Allows multiple uses of the same custom hook
- Enables dynamic prop usage within hooks at runtime
Advantages of Hooks over Render Props:
- Avoids deeply nested component structures
- Provides direct access to hook values at the component's top level
- Simplifies component composition and readability
Each pattern represents a problem-solving approach:
- HOCs tackled code reuse,
- Render Props managed complex state sharing,
- Hooks revolutionized component logic,
All of them are about making complex UI development more intuitive, readable, and maintainable. As the ecosystem evolves, these patterns remind us that great software design is about solving problems elegantly, not just writing code.
I hope you find this useful and remember, don't force some design pattern if it's not a 100% fit for that problem.
Comments ()