DEV Community

loading...
Cover image for Global State in Gatsby, or Having and Eating Your Cake, Too

Global State in Gatsby, or Having and Eating Your Cake, Too

Anna
React/Node/JS. Occasional serverless thought-haver @ stackery.io. Full-stack human.
・8 min read

Gatsby is a fantastic way to build and maintain static websites. React is a fantastic way to build and maintain dynamic web applications. But what if one wants to dream the impossible improbable dream of having your static cake while eating it dynamically, too? (Too many metaphors, I know.)

This was the problem I encountered when I needed to add and persist user data across an existing Gatsby site, without screwing up what was already there. Hopefully this will help the next developer who also dares to dream that improbable dream.

Impossible dream gif

The big Why?

First of all, why was I setting out on this improbable task of adding global state to a static site? Was I so bored at work that my PM decided to let me run amok and make Gatsby harder? Not quite (though I do look forward to that day).

In reality, we were implementing a new feature that requires users to log in to be authenticated with Github, so that we could have access to visualize and run an audit on a private repository. That means we needed to persist that user state across several components, like the navbar that showed a different button depending on the user's logged-in state, as well as the components handling the audit logic. Thus far, we'd only used local state in the site, scoped to each component doing its thing. This was going to be a whole new world of Gatsby functionality for me.

Aladdin gif

Finding imperfect solutions

The first task I set upon myself was research: was there a Gatsby plugin that could already do what I needed? (Spoiler alert: nope). How about existing tutorials? I already knew what I needed: global state throughout my Gatsby app. I needed to learn the how, or at least be pointed to potential solutions. This short blog post on global state in Gatsby gave me a great start, and led me to consider React's Context as a potential solution.

The next step for me is always: read the docs! Specifically, the React Context docs. Providers! Consumers! Ok, this sounds like exactly what I need, except this was the React example...

class App extends React.Component {
  constructor(props) {
    super(props);

    this.toggleTheme = () => {
      ...
    };
  }

  render() {
    return (
       <ThemeContext.Provider value={this.state.theme}>
           <Toolbar changeTheme={this.toggleTheme} />
       </ThemeContext.Provider>
    );
  }
}

ReactDOM.render(<App />, document.root);
Enter fullscreen mode Exit fullscreen mode

Huh. React apps have an App component. Gatsby apps do not. So I needed a Gatsby-specific way to do this Context thing. More research!

Research gif

How about the Gatsby docs? That's where I found this incredibly helpful tutorial on React Context with Gatsby, which got me started on the right path. Unfortunately, it's from 2019, which may as well be the prehistoric era when we're talking about React advancements. The example uses class components and no hooks (😱), and is all the way back in React 16.3, while we'd already been in the trenches with 16.8, and were up to 17. What are we, savages? It was definitely time for an update.

Cake-baking time

With a decent understanding of React Context from their docs, as well as knowing I wanted to implement global state using React Hooks, specifically useContext() and useState(), I set about customizing and updating the sample code I had found to work for my use case.

Building a user context and provider

In a nutshell, React Context is a way to set and use global state without passing a prop to every component that needs it. Instead, you create a Context, then set a Provider that provides that context throughout the application, and a Consumer that, well, consumes (or makes available) that context. Then, you use the useContext() hook to get the value of the global state object and, if needed, the function that sets the value in individual components.

In my src directory, I created contexts/UserContext.js and added the following:

// ./src/contexts/UserContext.js
import React, { createContext, useEffect, useState } from 'react';
import { getCurrentUser } from '../utils/cognito';

const defaultState = {
  user: {
    loggedIn: false,
    userid: null,
    username: null
  },
  setUser: () => {}
};

const UserContext = createContext(defaultState);

const UserProvider = (props) => {
  const [user, setUser] = useState(defaultState.user);

  useEffect(async () => {
    const currentUser = await getCurrentUser();
    if (currentUser) {
      // Set the current user in the global context
      setUser(prevState => {
        return {
          ...prevState,
          loggedIn: true,
          userid: currentUser.id,
          username: currentUser.email
        };
      });
    }
  }, []);

  const { children } = props;

  return (
    <UserContext.Provider
      value={{
        user,
        setUser
      }}
    >
      {children}
    </UserContext.Provider>
  );
};

export default UserContext;

export { UserProvider };
Enter fullscreen mode Exit fullscreen mode

Here, we're setting a defaultState - anyone who's used Redux should be familiar with that concept. It's the blank state every user visiting the website starts with.

Next, we're using React's createContext API to create a context object based on our default values for user and setUser.

Then, we use the useState hook to set the user object, and initialize the setUser function that will be used to update that global user object.

The next hook we use is useEffect - this was a new one for me, but essentially it's the Hooks-y way of triggering a ComponentDidMount / ComponentDidUpdate lifecycle event. When it's initialized with an empty array as in the example above, it acts as ComponentDidMount, in that it's only executed once on a render. That's perfect for our use case, as I want to call an async function called getCurrentUser (which is using the AWS Cognito API in the background to fetch user data), and if the user is already logged in, use the setUser hook to update the user object. If not, nothing happens and the user is still in the default state.

Finally, we use

    <UserContext.Provider
      value={{
        user,
        setUser
      }}
    >
      {children}
    </UserContext.Provider>
Enter fullscreen mode Exit fullscreen mode

to wrap all of the children elements with the context of user and setUser. Then we export both UserContext and UserProvider, as we'll need both throughout our codebase.

Wrap that root

So remember that example from the React docs that wrapped the root App component in the Provider? Yeah, that's not gonna work with Gatsby. Luckily, Gatsby has a super-handy wrapRootElement API that basically does the same thing, and it's implemented in gatsby-browser like so:

// ./gatsby-browser.js
import React from 'react';
import { UserProvider } from './src/contexts/UserContext';

export const wrapRootElement = ({ element }) => (
  <UserProvider>{element}</UserProvider>
);
Enter fullscreen mode Exit fullscreen mode

That's all there is to it! Now each component will have access to the UserProvider context.

What the Provider provides, the Consumer consumes

Next, we need a place for the UserProvider Consumer. This should be a parent element to the child components that will need access to the user context. In my codebase, I chose the Layout component, as it wraps just about every page of the site and is where we have another Provider already, the ThemeProvider. Your implementation may vary in this regard, but it's safe to say that most Gatsby starters do include a universal Layout component of some sort.

// ./src/layouts/Layout.jsx
import React, { Fragment } from 'react';
import { ThemeProvider } from '@emotion/react';
import { Footer, NavBar } from 'layouts';
import UserContext from '../contexts/UserContext';

const Layout = (props) => {
  return (
    <ThemeProvider theme={theme}>
      ...
      />
      <UserContext.Consumer>
        {user => (
          <Fragment>
            <NavBar />
            {props.children}
            <Footer />
          </Fragment>
        )}
      </UserContext.Consumer>
    </ThemeProvider>
  );
};
Enter fullscreen mode Exit fullscreen mode

As we can see, it's possible to use multiple Providers and Consumers within one app, though we'll keep our focus on the UserContext.Consumer.

Because we initialized

const UserContext = createContext(defaultState);
Enter fullscreen mode Exit fullscreen mode

back in the context, we can access both UserContext.Provider and UserContext.Consumer. The code above just places the consumer above all the children components of the app. Because of the way the Consumer is set up, it requires a function as a child. That's why we have <UserContext.Consumer> {user => (...)}</UserContext.Consumer>.

Note that we're not passing the user object to the NavBar component here, though we very well could with <NavBar user={props.user} setUser={props.setUser} />. But then, how would we pass that same data to {children}? That's where the handy useContext() hook comes in!

Hooks or it didn't happen

So we've got our Provider, we've got our Consumer in the site's Layout component, and now we need to pull the user object and, in some cases, the setUser function from the global context. Let's start with our NavBar component, which will either render a button or an icon depending on whether a user is logged in or not:

Logged out view
nav-logged-out

Logged in view
nav-logged-in

// ./src/layouts/NavBar.jsx
import React, { useContext } from 'react';
import UserContext from '../contexts/UserContext';
import { signOut } from '../utils/cognito';

const NavBar = () => {
  const { user, setUser } = useContext(UserContext);
  const handleSignOut = () => {
    signOut();
    setUser({
      user: {
        loggedIn: false,
        username: '',
        userid: ''
      }
    });
  };

  return (
    <Header>
      <Nav className='navbar navbar-expand-lg fixed-top'>
        <a href='/' className='navbar-brand'>
          <img src={logo} />
        </a>
        <ul>
          ...other nav items
          <li className='nav-item nav-button'>
            {user && user.loggedIn
              ? <UserIcon handleSignOut={handleSignOut} username={user.username}/>
              : <a href='https://app.stackery.io/'><Button type='button' text='Sign In' /></a>
            }
          </li>
        </ul>
      </Nav>
    </Header>
  );
};
Enter fullscreen mode Exit fullscreen mode

Let's start right at the top: we've got our useContext hook, and like a magical fairy pulling a bag of gold out of thin air, useContext pulls user and setUser out of the React ether and assures us that they're the global values our app depends on!

Magic

So with const { user, setUser } = useContext(UserContext);, we can now see if the user object exists, and if user.loggedIn is true, we'll show a component called UserIcon that shows the user icon and has a dropdown that allows the user to sign out. And as we can see in handleSignOut(), we use setUser to update the global user state back to the defaults when the user has signed out.

Context in class components

Finally, we've got a class component where we also need access to the user object and setUser function. There are two options for this: if you have a direct parent component that's a functional component, you can pass those values as props like so:

// ./src/pages/Registry.jsx
import React, { useContext } from 'react';
import { RegistryContainer } from 'components/registry';

const Registry = () => {
  const { user, setUser } = useContext(UserContext);

  return (
    <Layout>
        ...
       <RegistryContainer user={user} setUser={setUser} />
    </Layout>
  );
};

export default Registry;
Enter fullscreen mode Exit fullscreen mode

Then in RegistryContainer, we access user and setUser just as we would any other props in a class component:

// ./src/components/registry/RegistryContainer.jsx
import React, { Component } from 'react';

class RegistryContainer extends Component {
  constructor (props) {
    super(props);
    ...
    }

  async componentDidUpdate (prevProps, prevState) {
    const {
      user
    } = this.props;

    if (user.loggedIn && !user.githubAuthState) {
      // do the oauth things!
      ...
      // then update global user
        this.props.setUser({
          ...this.props.user,
          githubAuthState: githubAuthStates.AUTHED
        });
    }
  }
  ...
  render () {
    return (
      <RegistryForm
        ...
        user={this.props.user}
      />
    );
Enter fullscreen mode Exit fullscreen mode

Option two, which I did not end up implementing, uses the contextType class property, and would look something like this:

// example from https://www.taniarascia.com/using-context-api-in-react/
import React, { Component } from 'react';
import UserContext from '../contexts/UserContext';

class HomePage extends Component {
  static contextType = UserContext;

  componentDidMount() {
    const user = this.context

    console.log(user) // { name: 'Tania', loggedIn: true }
  }

  render() {
    return <div>{user.name}</div>
  }
}
Enter fullscreen mode Exit fullscreen mode

Either way should work depending on your codebase, I just went for the clean and simple useContext() option throughout.

The cake is not a lie!

Cake cat gif

And there we go: we've got access to our user anywhere we fancy on our Gatsby site, and all it took was a little bit of Context.

So now, if a user is logged in and on the Registry page, they'll see the same state in two different components:

logged-in-state

And if they're logged out, all the components know:
logged-out-state

Hope this is helpful to future Gatsby tinkerers, and feel free to ask questions or point out bugs (no, using semi-standard is not a bug) in the comments. And if you'd like to see this functionality live in the wild, check out stack.new for yourself!

Discussion (2)

Collapse
larsejaas profile image
LarsEjaas • Edited

It's a really nice trick to have the context provider in the gatsby-browser file!
I usually just wrap the other components in the layout file, but it's better to keep the logic in a seperate file, I like that solution 😊

It's also worth noting that if you do not use class components you do not really need the consumer component at all. You simply wrap the root with the provider and then get the context values using the context hook as you have already shown. This is actually quite clever 😊

Collapse
tujoworker profile image
Tobias Høegh

Thank You Anna for this good and nicely written article 💙👌

Forem Open with the Forem app