DEV Community

loading...

4 ways to handle local state when using Apollo and GraphQL

Johannes Kettmann
React + GraphQL
Originally published at jkettmann.com Updated on ・6 min read

Originally published on jkettmann.com

There are a lot of options for state-management in the React eco-system. Using Apollo and GraphQL is a great way to handle server-side data with little boilerplate. But the community is still young and there often has not been established a best practice yet. The question here is how to handle client-side state. What solutions are there and what are the pros and cons?

Handling client-side state with Apollo

With Apollo-Link-State there is an option to handle local state with Apollo itself. But to many, it's still a shrouded mystery. The docs are verbose in many occasions and debugging can be hard. At the same time, it's great to have all the data in the same location and not introduce yet another programming paradigm. One of the best use-cases for Apollo as client-state management system is when you need to enhance server-side data with local state.

Let's take a quick look at an example from this more detailed article about combining server and local data. We have a GraphQL server that exposes a list of books with following type.

type Book {
  id: String!
  author: String!
  title: String!
}

type Query {
  books: [Book]
}
Enter fullscreen mode Exit fullscreen mode

The goal is now to extend the Book type with a client-side boolean flag to indicate if it has been selected by the user. We can achieve this by passing a clientState object to the Apollo provider. The selected flag is added by the Book resolver and defaults to false. We also implement a toggleBook mutation. This gets the existing book data for a certain ID from the Apollo cache and toggles the selected flag.

import React from 'react';
import gql from 'graphql-tag';
import ApolloClient from 'apollo-boost';
import { ApolloProvider } from 'react-apollo';

import BookList from './BookList';

const clientState = {
  resolvers: {
    Book: {
      selected: (book) => book.selected || false,
    },
    Mutation: {
      toggleBook: (_, args, { cache, getCacheKey }) => {
        const id = getCacheKey({ id: args.id, __typename: 'Book' });
        const fragment = gql`
          fragment bookToSelect on Book {
            selected
          }
        `;
        const book = cache.readFragment({ fragment, id });
        const data = { ...book, selected: !book.selected };
        cache.writeFragment({ fragment, id, data });
        return null;
      },
    },
  },
};

const client = new ApolloClient({
  uri: 'http://localhost:4000/graphql',
  clientState,
});

const App = () => (
  <ApolloProvider client={client}>
    <BookList />
  </ApolloProvider>
);
Enter fullscreen mode Exit fullscreen mode

The book list includes the selected flag in the query annotated with the @client directive. This indicates to the Apollo client that this data needs to be resolved on the client.

import React from 'react';
import { Query } from 'react-apollo';
import gql from 'graphql-tag';
import Book from './Book';

const BOOKS_QUERY = gql`
  query {
    books {
      id
      author
      title
      selected @client
    }
  }
`;

const BookList = () => (
  <Query query={BOOKS_QUERY}>
    {
      ({ data }) => data.books && (
        <React.Fragment>
          {data.books.map(book => (
            <Book key={book.id} {...book} />
          ))}
        </React.Fragment>
      )
    }
  </Query>
);
Enter fullscreen mode Exit fullscreen mode

The book component calls the toggleBook mutation and provides its ID as variable. Inside the definition of the mutation we again use the @client directive.

import React from 'react';
import gql from 'graphql-tag';
import { Mutation } from 'react-apollo';
import './Book.css';

const SELECT_BOOK_MUTATION = gql`
  mutation {
    toggleBook(id: $id) @client
  }
`;

const Book = ({ id, author, title, selected }) => (
  <Mutation mutation={SELECT_BOOK_MUTATION}>
    {
      toggleBook => (
        <p
          className={selected ? 'selected' : 'not-selected'}
          onClick={() => toggleBook({ variables: { id } })}
        >
          {title} by {author}
        </p>
      )
    }
  </Mutation>
);
Enter fullscreen mode Exit fullscreen mode

Combining server and local data like this results in a consistent approach to fetching data inside our components. We could have kept the local data in a separate store like an array of selected book IDs inside a Redux store. But then we would have to check for every book if it is included in this array or not. This alone is not a big deal, of course. But if you think of the additional overhead of writing the read and write logic to get the data in and out of the store it's well worth taking Apollo for client-state management in consideration.

If you want to have a more detailed look at this and a more complex example check out this article about combining server-side data and local state with Apollo.

Handling global client state with React Context

The above example might seem like overkill for situations where you have local state that is not related to server-side data. In many cases, the built-in React API is actually sufficient. Let's take a look at common use-case: A modal window. This is probably not the best way to implement a modal but it's a good example for using React's context API.

We extend the above example with a Modal component, its context and a button to open it. The App component uses its local state to store information about whether the modal is open or not. It also has a function that allows switching the isModalOpen flag to true. The flag and the function are passed to the modal's context provider.

import React from 'react';
import ApolloClient from 'apollo-boost';
import { ApolloProvider } from 'react-apollo';
import Modal, { ModalContext } from '../Modal';
import OpenModalButton from '../OpenModalButton';
import BookList from '../BookList';

const client = new ApolloClient({
  uri: 'http://localhost:4000/graphql',
});

class App extends React.Component {
  state = {
    isModalOpen: false,
  }

  openModal = () => {
    this.setState({ isModalOpen: true });
  }

  render() {
    const { isModalOpen } = this.state;
    const openModal = this.openModal;
    return (
      <ApolloProvider client={client}>
        <ModalContext.Provider value={{ isModalOpen, openModal }}>
          <BookList />

          <OpenModalButton />
          <Modal />
        </ModalContext.Provider>
      </ApolloProvider>
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

The modal itself defines the context via React.createContext. The Modal component uses the context's consumer to get access to the context value which is defined in the App component. It only renders the actual modal when the isModalOpen flag is set.

import React from 'react';

const defaultContext = {
  isModalOpen: false,
  openModal: () => ({ isModalOpen: true }),
};

export const ModalContext = React.createContext(defaultContext);

const Modal = () => (
  <ModalContext.Consumer>
    {
      ({ isModalOpen }) => isModalOpen && (
        <div className="modal">
          This is a modal
        </div>
      )
    }
  </ModalContext.Consumer>
);
Enter fullscreen mode Exit fullscreen mode

The OpenModalButton component also uses the modal context's consumer to access the openModal function defined in the App component. Once the button is clicked the isModalOpen flag in the App component's state is toggled and the modal window becomes visible.

import React from 'react';
import { ModalContext } from '../Modal';

const OpenModalButton = () => (
  <ModalContext.Consumer>
    {
      ({ openModal }) => (
        <button onClick={openModal}>
          Open Modal
        </button>
      )
    }
  </ModalContext.Consumer>
);
Enter fullscreen mode Exit fullscreen mode

Using React's context API for client-side state is simple and probably much easier to implement if you never used Apollo for local state management before. In case you're interested in how this modal window can be implemented using Apollo you can refer to this article.

Component state for simple use-cases

Using React's context API, Apollo or another solution for managing global state are all valid approaches. But in many cases using simple component state is enough. Why risk a global re-render when the scope of the state is limited to a single component?

In the following example, we only want to show a small info box inside a component. Using global state would be over the top here since it's more complicated to implement and maintain.

import React from 'react';

class SomeComponent extends React.Component {
  state = {
    isInfoBoxOpen: false,
  }

  openInfoBox = () => {
    this.setState({ isInfoBoxOpen: true });
  }

  render() {
    return (
      <div className="container">
        <button onClick={this.openInfoBox}>
          Open info box
        </button>
        {
          this.state.isInfoBoxOpen && <InfoBox />
        }
      </div>
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Third-party state management solutions

If you still are in need of a different solution for state management you can, of course, use packages like Redux or Mobx. The disadvantage is that you introduce new dependencies and additional programming paradigms. At the same time, you add another source for the data making it more complicated to combine data from both sources if required.

Conclusion

Most cases for local state management can be covered by using the context API or component state if you don't want to migrate fully to Apollo. Apollo can be a bit complicated and verbose to use in the beginning but is a great solution when you need to extend server-side data with client-side state. In other situations, it might be overkill but at least you would have the benefit of being able to use the Apollo dev tools.

Discussion (2)

Collapse
xngwng profile image
Xing Wang

Nice tutorial.

Collapse
jkettmann profile image
Johannes Kettmann Author

Thanks a lot :-)