Introduction to State Management in Relay
Relay is a powerful GraphQL client developed by Meta (formerly Facebook) that emphasizes declarative data fetching, colocation of queries with components, and efficient data normalization. While Relay excels at managing server-side data through its normalized store, real-world applications inevitably require managing local state โ UI state, form inputs, optimistic updates, and client-only fields. Understanding how to integrate and structure this state alongside Relay's data layer is essential for building maintainable React applications.
This tutorial explores the patterns and libraries available for managing state in Relay applications. We will cover Relay's built-in mechanisms for local state, how to combine Relay with external state management libraries, and best practices for keeping your application architecture clean and predictable.
What Is State Management in Relay?
State management in Relay refers to how you handle data that lives outside the server's GraphQL schema but still needs to be accessible within your React components. Relay's normalized cache handles server data automatically โ it deduplicates entities, updates references, and garbage collects unused records. However, Relay does not natively manage purely client-side concerns like:
- UI state (modal visibility, selected tabs, dark mode toggles)
- Form state (input values, validation errors, submission status)
- Client-only derived data (filtered lists, computed aggregations)
- Optimistic updates and local mutations
- Authentication tokens and session metadata
Relay provides several built-in features to address some of these needs, including local schema extensions, client-side mutations, and the @updatable directive. For more complex scenarios, developers often pair Relay with dedicated state management libraries.
Why State Management Matters in Relay
Without a clear state management strategy, Relay applications can quickly become difficult to reason about. Mixing server data with local concerns in ad-hoc ways leads to bugs, inconsistent UI, and code that is hard to test. A well-defined approach to state management provides several benefits:
- Predictability: Clear boundaries between server and client state make data flow easier to trace.
- Performance: Avoiding unnecessary re-renders by keeping local state scoped appropriately.
- Testability: Isolated state logic is easier to unit test and mock.
- Developer experience: Consistent patterns reduce onboarding time for new team members.
Relay's philosophy is to keep server data in its normalized store and let you decide how to handle the rest. This flexibility is powerful but requires deliberate architectural decisions.
Relay's Built-In State Management Mechanisms
Local Schema Extensions
Relay allows you to extend the server's GraphQL schema with client-only fields using the @relay_client_definition and local schema files. This lets you define fields that exist only on the client and query them just like server fields.
# schemaExtensions.graphql
extend type User {
isSelected: Boolean!
localNote: String
}
extend type Todo {
isEditing: Boolean!
}
Once your schema extension is configured in your relay.config.js, you can query these client fields in your components:
import { graphql, useFragment } from 'react-relay';
const TodoItem = (props) => {
const todo = useFragment(
graphql`
fragment TodoItem_todo on Todo {
id
text
completed
isEditing @client
}
`,
props.todo
);
return (
<div>
{todo.isEditing ? (
<input defaultValue={todo.text} />
) : (
<span>{todo.text}</span>
)}
</div>
);
};
The @client directive tells Relay that this field is local and should not be sent to the server. You can then update these fields using local resolvers or client-side mutations.
Client-Side Mutations
Relay supports client-side mutations that update local state without hitting the server. These are useful for toggling UI flags, updating local fields, and performing optimistic updates.
import { commitLocalUpdate, graphql } from 'react-relay';
function toggleTodoEditing(environment, todoId, isEditing) {
commitLocalUpdate(environment, (store) => {
const todo = store.get(todoId);
if (todo) {
todo.setValue(isEditing, 'isEditing');
}
});
}
// Usage in a component
const handleEditClick = () => {
toggleTodoEditing(environment, todo.id, true);
};
commitLocalUpdate gives you direct access to Relay's normalized store, allowing you to read and write records imperatively. This is ideal for quick local updates that do not require a server round trip.
Updatable Fragments
Relay's useUpdatableQuery and updatable fragments allow you to read and update local data reactively. This is particularly useful for managing UI state that depends on server data.
import { useUpdatableQuery, graphql } from 'react-relay';
const FilterPanel = () => {
const [queryRef, loadQuery] = useUpdatableQuery(
graphql`
query FilterPanelQuery @updatable {
filterText @client
selectedCategory @client
}
`,
{}
);
const data = usePreloadedQuery(
graphql`
query FilterPanelQuery @updatable {
filterText @client
selectedCategory @client
}
`,
queryRef
);
return (
<div>
<input
value={data.filterText || ''}
onChange={(e) => {
commitLocalUpdate(environment, (store) => {
store.getRoot().setValue(e.target.value, 'filterText');
});
}}
/>
</div>
);
};
Combining Relay with External State Libraries
For complex applications, Relay's built-in local state features may not be sufficient. Many teams pair Relay with a dedicated state management library. Below are common patterns for integrating popular libraries.
Relay with React Context and useReducer
For small to medium applications, React's built-in Context API combined with useReducer is often sufficient. This approach keeps UI state separate from Relay's data store while avoiding additional dependencies.
import { createContext, useContext, useReducer } from 'react';
const UIStateContext = createContext();
const initialState = {
selectedTodoId: null,
isModalOpen: false,
theme: 'light',
};
function uiStateReducer(state, action) {
switch (action.type) {
case 'SELECT_TODO':
return { ...state, selectedTodoId: action.payload };
case 'TOGGLE_MODAL':
return { ...state, isModalOpen: !state.isModalOpen };
case 'SET_THEME':
return { ...state, theme: action.payload };
default:
return state;
}
}
export const UIStateProvider = ({ children }) => {
const [state, dispatch] = useReducer(uiStateReducer, initialState);
return (
<UIStateContext.Provider value={{ state, dispatch }}>
{children}
</UIStateContext.Provider>
);
};
export const useUIState = () => {
const context = useContext(UIStateContext);
if (!context) {
throw new Error('useUIState must be used within UIStateProvider');
}
return context;
};
Components can then consume this context alongside Relay hooks without any conflict:
import { useFragment, graphql } from 'react-relay';
import { useUIState } from './UIStateContext';
const TodoList = (props) => {
const { state, dispatch } = useUIState();
const data = useFragment(
graphql`
fragment TodoList_todos on Query @relay(plural: true) {
id
text
completed
}
`,
props.todos
);
return (
<ul>
{data.map((todo) => (
<li
key={todo.id}
style={{
fontWeight: state.selectedTodoId === todo.id ? 'bold' : 'normal',
}}
onClick={() => dispatch({ type: 'SELECT_TODO', payload: todo.id })}
>
{todo.text}
</li>
))}
</ul>
);
};
Relay with Zustand
Zustand is a lightweight state management library that works well alongside Relay. It provides a simple API for creating stores without boilerplate, and it integrates cleanly with React's rendering model.
import { create } from 'zustand';
const useUIStore = create((set) => ({
selectedTodoId: null,
isModalOpen: false,
filterText: '',
setSelectedTodo: (id) => set({ selectedTodoId: id }),
toggleModal: () => set((state) => ({ isModalOpen: !state.isModalOpen })),
setFilterText: (text) => set({ filterText: text }),
}));
export default useUIStore;
Using the store in a component is straightforward:
import { useFragment, graphql } from 'react-relay';
import useUIStore from './uiStore';
const TodoList = (props) => {
const selectedTodoId = useUIStore((s) => s.selectedTodoId);
const setSelectedTodo = useUIStore((s) => s.setSelectedTodo);
const filterText = useUIStore((s) => s.filterText);
const data = useFragment(
graphql`
fragment TodoList_todos on Query @relay(plural: true) {
id
text
completed
}
`,
props.todos
);
const filteredTodos = data.filter((todo) =>
todo.text.toLowerCase().includes(filterText.toLowerCase())
);
return (
<ul>
{filteredTodos.map((todo) => (
<li
key={todo.id}
style={{
fontWeight: selectedTodoId === todo.id ? 'bold' : 'normal',
}}
onClick={() => setSelectedTodo(todo.id)}
>
{todo.text}
</li>
))}
</ul>
);
};
Zustand's selector-based subscription model ensures that components only re-render when the specific state they depend on changes, which pairs well with Relay's own subscription optimizations.
Relay with Redux Toolkit
For larger applications with complex state logic, Redux Toolkit remains a popular choice. The key to integrating Redux with Relay is to keep their responsibilities clearly separated: Relay manages server data, Redux manages application-level state.
import { configureStore, createSlice } from '@reduxjs/toolkit';
const uiSlice = createSlice({
name: 'ui',
initialState: {
selectedTodoId: null,
isModalOpen: false,
theme: 'light',
},
reducers: {
selectTodo: (state, action) => {
state.selectedTodoId = action.payload;
},
toggleModal: (state) => {
state.isModalOpen = !state.isModalOpen;
},
setTheme: (state, action) => {
state.theme = action.payload;
},
},
});
export const { selectTodo, toggleModal, setTheme } = uiSlice.actions;
export const store = configureStore({
reducer: {
ui: uiSlice.reducer,
},
});
Components use Redux hooks for UI state and Relay hooks for server data:
import { useSelector, useDispatch } from 'react-redux';
import { useFragment, graphql } from 'react-relay';
import { selectTodo } from './store';
const TodoList = (props) => {
const dispatch = useDispatch();
const selectedTodoId = useSelector((state) => state.ui.selectedTodoId);
const data = useFragment(
graphql`
fragment TodoList_todos on Query @relay(plural: true) {
id
text
completed
}
`,
props.todos
);
return (
<ul>
{data.map((todo) => (
<li
key={todo.id}
onClick={() => dispatch(selectTodo(todo.id))}
style={{
backgroundColor:
selectedTodoId === todo.id ? '#e0f0ff' : 'transparent',
}}
>
{todo.text}
</li>
))}
</ul>
);
};
Relay with Jotai
Jotai offers an atomic approach to state management, where state is broken down into small, composable units called atoms. This model works particularly well when local state needs to be derived from Relay data.
import { atom, useAtom, useAtomValue } from 'jotai';
import { useFragment, graphql } from 'react-relay';
// Atoms for UI state
const selectedTodoIdAtom = atom(null);
const filterTextAtom = atom('');
// Derived atom that depends on Relay data
const createFilteredTodosAtom = (todos) =>
atom((get) => {
const filterText = get(filterTextAtom);
if (!filterText) return todos;
return todos.filter((todo) =>
todo.text.toLowerCase().includes(filterText.toLowerCase())
);
});
const TodoList = (props) => {
const data = useFragment(
graphql`
fragment TodoList_todos on Query @relay(plural: true) {
id
text
completed
}
`,
props.todos
);
const [selectedTodoId, setSelectedTodoId] = useAtom(selectedTodoIdAtom);
const [filterText, setFilterText] = useAtom(filterTextAtom);
const filteredTodos = useAtomValue(createFilteredTodosAtom(data));
return (
<div>
<input
value={filterText}
onChange={(e) => setFilterText(e.target.value)}
placeholder="Filter todos..."
/>
<ul>
{filteredTodos.map((todo) => (
<li
key={todo.id}
onClick={() => setSelectedTodoId(todo.id)}
style={{
fontWeight: selectedTodoId === todo.id ? 'bold' : 'normal',
}}
>
{todo.text}
</li>
))}
</ul>
</div>
);
};
Optimistic Updates in Relay
Optimistic updates are a critical pattern for responsive UIs. Relay provides built-in support for optimistic mutations through the optimisticUpdater and optimisticResponse options in commitMutation.
import { commitMutation, graphql } from 'react-relay';
const toggleTodoMutation = graphql`
mutation ToggleTodoMutation($input: ToggleTodoInput!) {
toggleTodo(input: $input) {
todo {
id
completed
}
}
}
`;
function toggleTodo(environment, todoId, completed) {
commitMutation(environment, {
mutation: toggleTodoMutation,
variables: {
input: { id: todoId, completed },
},
optimisticResponse: {
toggleTodo: {
todo: {
id: todoId,
completed,
},
},
},
onError: (error) => {
console.error('Mutation failed, reverting optimistic update:', error);
// Relay automatically reverts optimistic updates on error
},
});
}
For more complex optimistic updates that involve multiple records, use optimisticUpdater:
commitMutation(environment, {
mutation: addTodoMutation,
variables: { input: { text: newTodoText } },
optimisticUpdater: (store) => {
const root = store.getRoot();
const todos = root.getLinkedRecords('todos') || [];
const newTodo = store.create(`client-new-todo-${Date.now()}`, 'Todo');
newTodo.setValue(newTodoText, 'text');
newTodo.setValue(false, 'completed');
root.setLinkedRecords([newTodo, ...todos], 'todos');
},
updater: (store) => {
const payload = store.getRootField('addTodo');
const newTodo = payload.getLinkedRecord('todo');
const root = store.getRoot();
const todos = root.getLinkedRecords('todos') || [];
root.setLinkedRecords([newTodo, ...todos], 'todos');
},
});
Best Practices for State Management in Relay
Separate Server State from Client State
The most important principle is to keep server data in Relay and client-only data in your chosen state library. Avoid duplicating server data in local stores, as this leads to synchronization issues and stale data. If you need derived data from server responses, compute it at render time or use memoization rather than copying it into local state.
Use Client Fields for UI Flags Tied to Entities
When UI state is directly associated with a specific entity (like an isEditing flag on a todo), use Relay's @client fields. This keeps the state co-located with the entity in Relay's normalized store and automatically cleans up when the entity is garbage collected.
Use External Stores for Global UI State
For global UI concerns like theme, navigation state, or modal management, use an external store like Zustand, Jotai, or Context. These states are not tied to specific GraphQL entities and benefit from being managed independently.
Keep Selectors Granular
When using external state libraries, always use granular selectors to subscribe to only the state your component needs. This prevents unnecessary re-renders and keeps performance optimal.
// Good: subscribes only to selectedTodoId
const selectedTodoId = useUIStore((s) => s.selectedTodoId);
// Bad: subscribes to entire store, re-renders on any change
const store = useUIStore();
const selectedTodoId = store.selectedTodoId;
Handle Loading and Error States Explicitly
Relay provides isLoading and error information through its hooks. Make sure your local state management accounts for these states, especially when local actions depend on server data being available.
const TodoApp = () => {
const [filterText, setFilterText] = useUIStore((s) => [
s.filterText,
s.setFilterText,
]);
const data = usePreloadedQuery(
graphql`
query TodoAppQuery {
todos {
id
text
completed
}
}
`,
queryRef
);
if (!data.todos) {
return <div>Loading...</div>;
}
const filtered = data.todos.filter((todo) =>
todo.text.includes(filterText)
);
return (
<div>
<input value={filterText} onChange={(e) => setFilterText(e.target.value)} />
<TodoList todos={filtered} />
</div>
);
};
Test State Logic in Isolation
Both Relay mutations and external state reducers should be unit-testable in isolation. For Relay, use RelayMockEnvironment to test mutations and local updates. For external stores, test reducers and actions independently of React components.
import { createMockEnvironment } from 'relay-test-utils';
import { toggleTodo } from './todoMutations';
describe('toggleTodo', () => {
it('applies optimistic update correctly', () => {
const environment = createMockEnvironment();
toggleTodo(environment, 'todo-1', true);
const store = environment.getStore();
const todo = store.get('todo-1');
expect(todo.getValue('completed')).toBe(true);
});
});
Avoid Over-Normalizing Local State
One common mistake is trying to replicate Relay's normalization strategy in local state libraries. Local state is typically simpler and does not need the same level of normalization. Keep local state flat and focused on specific concerns.
Conclusion
State management in Relay applications requires a thoughtful approach that leverages Relay's built-in capabilities for server-adjacent state while using external libraries for purely client-side concerns. By extending the schema with @client fields for entity-specific UI flags, using commitLocalUpdate for direct store manipulation, and pairing Relay with lightweight libraries like Zustand or Jotai for global UI state, you can build applications that are both performant and maintainable. The key is maintaining clear boundaries: let Relay own server data, let external stores own UI state, and use derived values at render time rather than duplicating data across systems. Following these patterns will help you scale your Relay application without accumulating technical debt in your state management layer.