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.
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.
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);
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!
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 };
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>
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>
);
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>
);
};
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);
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:
// ./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>
);
};
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!
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;
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}
/>
);
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>
}
}
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!
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:
And if they're logged out, all the components know:
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!
Top comments (2)
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 😊
Thank You Anna for this good and nicely written article 💙👌