โ† Back to DevBytes

State Management in Relay: Patterns and Libraries

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:

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:

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.

๐Ÿ›  Tools from DevBytes

Inventory Tracker Pro โ€” Excel inventory system, low-stock alerts ยท $19
AI Dev Kit for Mac โ€” local AI dev environment templates ยท $9.99
KeyMapper for Mac โ€” custom keyboard shortcut toolkit ยท $7.99

โ† Back to all articles