State management in frontend applications is one of those areas where things can quickly get complicated. Whether you’re dealing with a simple form or a complex dashboard, managing state across multiple components is a challenge that every developer faces. In today’s issue of The Pragmatic Developer, we’re going to dive into practical strategies for keeping your UI in sync with your data—focusing on React, but touching on concepts that are applicable across the board.
Synchronizing State Across Components
In regular frontend applications, you've likely encountered situations where you need to sync state between two or more components when the underlying data changes. Traditionally, this involves carefully designing the response from an endpoint and updating the corresponding UI when the response is returned.
A typical use case is adding a new item to a list. For instance, when adding a new Todo
item to a TodoApp
, an endpoint like the following is common:
const TodoList = () => {
const [items, setItems] = useState<Item[]>([]);
const onSubmit = async () => {
const response = await fetch('/api/todos', {
method: 'POST',
body: JSON.stringify({ content: todo })
});
const item = await response.json();
setItems((prevItems) => [...prevItems, item]);
};
return (
// JSX for rendering the list and form
);
};
Optimistic update
You could also introduce an optimistic update, which means updating the UI before the server confirms success. If the operation fails, you can roll back the change, though this adds a bit of complexity:
const onSubmit = async () => {
const temporaryId = Date.now().toString();
setItems((prevItems) => [...prevItems, { content: todo, id: temporaryId }]);
try {
const response = await fetch('/api/todos', {
method: 'POST',
body: JSON.stringify({ content: todo })
});
const item = await response.json();
setItems((prevItems) =>
prevItems.map((i) => (i.id === temporaryId ? item : i))
);
} catch (error) {
setItems((prevItems) =>
prevItems.filter((i) => i.id !== temporaryId)
);
// Handle the error, e.g., show a notification
}
};
Even though this adds some additional logic, the complexity is still manageable. However, this example is relatively simple and probably oversimplified—consider a scenario where you need to update multiple parts of the UI, not just the TodoList
.
Managing State Across Multiple Components
Let's say the user can add an item to their favorite list, and several places in the UI need to reflect this change. For example, there could be a dropdown menu in the navigation that lists all favorite boards, with each item linking to a board detail page. On the same page, a button next to the board name allows toggling the favorite status. Now, you need to synchronize all these states across different components that aren't directly connected.
To achieve this, you would typically use a root-level context that shares the state across all children, exposing a setter
function to change the state.
React context is an excellent solution for this scenario.
type FavoriteBoardContextType = {
favoriteBoards: FavoriteBoard[];
toggleFavorite: (boardId: string) => void;
};
const FavoriteBoardContext = createContext<FavoriteBoardContextType | null>(null);
const FavoriteBoardProvider = ({ children }) => {
// might get the list from API call initially
const [favoriteBoards, setFavoriteBoards] = useState<FavoriteBoard[]>();
const toggleFavorite = (boardId: string) => {
setFavoriteBoards((prevBoards) => {
if (prevBoards.some((board) => board.id === boardId)) {
return prevBoards.filter((board) => board.id !== boardId);
}
return [...prevBoards, { id: boardId, name: "New Board Name" }];
});
};
return (
<FavoriteBoardContext.Provider value={{ favoriteBoards, toggleFavorite }}>
{children}
</FavoriteBoardContext.Provider>
);
};
To allow components further down the tree to access this context, you can expose a custom hook:
export function useFavoriteBoards() {
const context = useContext(FavoriteBoardContext);
if (!context) {
throw new Error('must be used within a FavoriteBoardProvider');
}
return context;
}
Now, in a deeply nested component, you might have logic like this to trigger changes:
const { favoriteBoards, toggleFavorite } = useFavoriteBoards();
const handleFavoriteBoard = () => {
toggleFavorite(data.id);
};
In other places that need to read the favorite state, such as the navigation, you can access the root-level context:
export function Navigation() {
const { favoriteBoards } = useFavoriteBoards();
// Render the menu item list
return (
// JSX for rendering the navigation menu
);
}
Exploring Alternatives
While React's Context API is a powerful tool for managing shared state, it's not the only option available. Depending on the complexity and scale of your application, you might consider other state management solutions:
Redux: A more robust and scalable state management library, Redux is particularly well-suited for large applications where state changes are complex and frequent. It provides a centralized store, making it easier to track and debug state changes.
Zustand: For those looking for a lightweight and more flexible alternative to Redux, Zustand is an excellent option. It provides a global state without the boilerplate, using React hooks and simple, easy-to-read APIs.
Relay: As mentioned earlier, Relay is a GraphQL client that automatically manages data fetching and state updates. It normalizes your data and keeps your UI in sync with minimal effort, making it ideal for applications that rely heavily on GraphQL.
Each of these tools has its own strengths and trade-offs, so it's essential to consider the specific needs of your application before choosing one. In many cases, React's built-in tools like Context and useReducer might be all you need. But as your application grows, it's worth exploring these alternatives to see which one aligns best with your requirements.
Updating Only Part of the UI
Let's consider a scenario where you need to update partial data of an entity, such as the title of a card. When a card is clicked, a popup is displayed, and the initial card details are passed to populate the UI.
Think of the implementation would be something like the following struture:
const Card = () => {
const [card, setCard] = useState<CardType | null>(null);
const [isOpen, setOpen] = useState(false);
return (
<>
{isOpen && <CardEditor card={card} />}
// Other JSX
</>
);
};
In the CardEditor
, we need to call an endpoint to update the title:
const onUpdateTitle = async (title: string) => {
const response = await fetch(`/api/card/${id}`, {
method: 'PUT',
body: JSON.stringify({ title })
});
const updatedCard = await response.json();
// Update state with the new title
};
To notify the Card
component of the change, you can pass data back via a callback:
<CardEditor
card={card}
onTitleChange={}
onDescriptionChange={}
/>
However, this approach becomes cumbersome as the number of fields in the popup increases, along with the states you have to manage.
Using Shared State for Better Scalability
A shared state can alleviate this problem. Instead of passing callbacks, you can use a context to manage the state:
const Card = () => {
const [card, setCard] = useState<CardType | null>(null);
const updateCard = (key: string, value: string) => {
setCard((prevCard) => (prevCard ? { ...prevCard, [key]: value } : prevCard));
};
return (
<CardContext.Provider value={{ card, updateCard }}>
{isOpen && <CardEditor />}
</CardContext.Provider>
);
};
In the CardEditor
, you can access the card
and updateCard
to modify the state as needed:
const CardEditor = () => {
const { card, updateCard } = useCard();
const onUpdateTitle = async (title: string) => {
const response = await fetch(`/api/card/${card.id}`, {
method: 'PUT',
body: JSON.stringify({ title })
});
const updatedCard = await response.json();
updateCard('title', updatedCard.title);
};
const onUpdateDescription = async (description: string) => {
const response = await fetch(`/api/card/${card.id}`, {
method: 'PUT',
body: JSON.stringify({ description })
});
const updatedCard = await response.json();
updateCard('description', updatedCard.description);
};
};
While context might seem overkill in some cases, it illustrates how shared, top-level state and updaters can connect otherwise isolated components.
Relay, for example, uses this pattern extensively with a centralized store. It manages data efficiently by normalizing it, ensuring that only the components that need re-rendering are updated.
I recently discovered that Relay offers a great pattern for managing data synchronization, easing much of the burden on developers. While it does have its own drawbacks, exploring those details goes beyond the scope of this article. However, it's definitely worth diving into the tutorials and exploring it if you haven't already.
Conclusion
The state management strategies we’ve discussed—whether using React’s built-in tools or exploring more advanced alternatives like Redux or Relay—are essential for building scalable and maintainable frontend applications. The right approach depends on your application’s complexity and specific requirements, but the goal remains the same: keeping your UI in sync with your data in a clean, efficient way.
What strategies have you found effective in your projects? Do you prefer using React’s Context API, or have you had better success with other state management libraries? I’d love to hear about your experiences and challenges in managing state across your applications. Feel free to reply to this email or share your thoughts in the comments.
And if you're interested in a deeper dive into Relay or other state management patterns, let me know! I’m always looking for topics that resonate with you, the readers of The Pragmatic Developer.