I've been using Next.js in production since version 10, and I'm a big fan overall. Like everything in life though, Next.js isn't perfect. One of my biggest pain points isn't actually due to Next.js itself, but rather a misalignment between Next.js and modern state management tools: managing page-specific bootstrap data.
So I create an open source library to fix it: React Strapped
Background
Single-Page Applications (SPAs) have been the predominant method of writing applications for some time in the web world, and for good reason. SPAs brought us high performance when navigating around the sight, as well as the ability for more complex state and context across routes. SPAs have their own downsides though, and is why we're starting to migrate away from them.
Next.js is a SPA-like system in the sense that Next.js provides client-side routing, instead of doing full page loads when navigating between routes. However, Next.js isn't really a SPA system because each route has its own code bundles, bootstrap data, etc. that are by default independent between pages. This allows each route to only need to load the data and code that route needs, increasing performance. Due to this hybrid nature, I've personally started calling Next.js' a "client-side MPA" framework, or csMPA.
I really appreciate Next.js' csMPA implementation because it greatly simplifies implementing and maintaining applications with multiple and varied routes compared with previous SPA architectures.
There's one area though that I've historically struggled with while using Next.js: how to access bootstrap data in a reliable and scalable manner. To be clear, accessing bootstrap data is outside of Next.js' jurisdiction and is instead the responsibility of state management libraries. Unfortunately I haven't found any state management libraries that handle bootstrap data from Next.js cleanly.
So I implemented my own library specifically to solve this use case: React Strapped.
Problem statement [1]
Before I dive into how React Strapped works, let's spend some time really defining the problem, because it wasn't immediately obvious to me.
React has a number of popular state management libraries, such as Redux, Zustand, Jotai, and Recoil. While each of these state management libraries brings their own opinions to state management, none of them have opinions on how to load bootstrap data from Next.js. Specifically, bootstrap data in Next.js a) varies from page to page and b) is not available to state management libraries until first render. As we'll see, this creates friction.
Bootstrap data in csMPAs typically have the following characteristics:
- Some data is always available on all routes.
- Examples include: data about the current user, feature/experimentation flags, etc.
- This data is always available, and can be consumed "no questions asked."
- In Next.js, this data is typically populated via a centralized helper function that is called from
getServerSideProps
.
- Other data is only available on certain routes.
- Examples include settings information that's only available on a settings page, but not on the home page.
- This data is not available on all pages, and so care must be take to only access this data on the settings page.
- In Next.js, this data is typically populated via one-off code in a specific route(s)'
getServerSideProps
implementation.
- Bootstrap data is not available until the first render of the application.
- In Next.js, bootstrap data generated in
getServerSideProps
is passed as component properties to the top level component. - The implication of this pattern is that any state management declared at module scope, such as
atom()
constructors in Jotai and Recoil, do not have access to this data at time of construction. - This limitation means we can't use these libraries preferrerd state initialization techniques.
- In Next.js, bootstrap data generated in
- Data during SSR needs to be scoped to a React context and not be available globally.
- A Next.js server is rendering multiple requests from multiple users more or less at once, meaning global data is not a pragmatic option since it wouldn't be "scoped" to a specific request.
- While it's possible to use global data safely in Next.js applications, it's very tricky to completely prevent all data "leaks" at all times. As such, I think it's best to avoid this pattern as whole.
- This means we can't do tricks for Jotai/Recoil like creating a global promise we attach to each atom's initializer, and then resolving it once we get the bootstrap data.
Taking these characteristics into account when considering state management libraries, we end up with these requirements for state management:
- Initialize the store/atoms/etc. synchronously during the first render
- Allow global data to be used anywhere, but prevent page specific data from being used outside of that page
- Only load state management code on a page if they are used on that page
Redux/Zustand handle requirement 1 well. Jotai/Recoil handle requirements 1 and 3 well, but only if we're not trying to solve requirement 2 (see below for an explanation). None of these four handle requirement 2 well.
React Strapped exists to handle all 3 requirements well. That said, React Strapped is not a replacement for the four state management libraries mentioned, and is indeed intended to be used in conjunction with them.
Wait, why doesn't Jotai/Recoil handle requirement 2? [2]
It may not be immediately obvious why Jotai/Recoil don't handle all requirements well. It certainly wasn't obvious to me at first, and my frustrations getting them to work specifically is what led me to create React Strapped. But why is that? It turns out that initializing Recoil with runtime bootstrap data is tricky and non-obvious
Specific challenges include:
- In multi-page apps, Jotai/Recoil must be initialized with bootstrap data that's been prop-drilled to a component
- Thus, bootstrap data is not available to use with the default value mechanisms Jotai/Recoil provide, preventing us from using the standard way of initializing atoms.
- The recommended way to solve this challenge in Recoil is to initialize atoms with the
initializeState
property inRecoilRoot
. As we'll see in challenge 2, this is not viable in multi-page apps. - Similarly, Jotai provides the
useHydrateAtoms
hook, but also isn't viable for the same reasons
- Bootstrap data varies from page to page
- Thus, we need to initialize different atoms depending on which route we're on.
- While it's possible, if unwieldy, to use a
switch(currentRoute)
statement to initialize only the atoms needed per route, we have to statically import every atom from every page to do the initialization. In other words, we're including code in our bundles that's never used. - Dynamic imports are typically how we include dependencies conditionally, but their asynchronous nature is incompatible with the synchronous
initializeState
prop/useHydrateAtoms
hook, which is otherwise the only way to conditionally import atoms based on the current route.
- State needs to be scoped to a React context and not used globally
- A Next.js server is rendering multiple requests from multiple users more or less at once, meaning global data is not an option since it wouldn't be "scoped" to a specific request.
- This means we can't do tricks like creating a global promise we attach to each atoms
default
prop, and then resolving it once we get the bootstrap data.
All of these challenges together mean that Jotai/Recoil do not currently include any mechanisms for conveniently initializing themselves with bootstrapped data in csMPAs.
How React Strapped works [3]
React Strapped works by creating a React context to hold bootstrap data, and special hooks for accessing this data. We call an instance of provider+hooks associated with a piece of bootstrap data a "strap." Unlike other state management libraries, these providers+hooks are intentionally designed so that more than one provider can be used at a time without impacting other providers. We'll see what this means and why it's important in the next section.
Simple example
This example shows a minimal example using React Strapped. It's written in TypeScript to a) demonstrate how TypeScript types flows through the library and b) to give a sense of what data is expected where. You can absolutely use this library without using TypeScript though.
First, let's create our provider and hooks in a file called state.ts
:
import { createStrappedProvider } from 'react-strapped';
export interface MyBootstrapData {
currentUser: {
name: string;
age: number;
};
}
// First, we create the strap, which includes the context
// provider and some helper functions for creating hooks.
const myStrap = createStrap<MyBootstrapData>();
// Next, export the provider, which is used to make data
// available to components.
export const MyStrapProvider = myStrap.Provider;
// Finally create a hook for accessing the current user
// included in the bootstrap data. The callback is called
// once on first render to initialize the strap value
// returned by the hook.
export const useCurrentUser = myStrap.createUseStrappedValue(
({currentUser}) => currentUser
);
Now let's create some UI in a Next.js page component:
import type { MyBootstrapData } from './state';
import { MyStrapProvider, useCurrentUser } from './state';
interface PageProps {
bootstrapData: MyBootstrapData;
}
// If you're not familiar with Next.js, this function runs
// on a server and is responsible for fetching bootstrap
// data. The value of the `props` property is passed as
// props to the default export React component in this file.
export function getServerSideProps() {
const props: PageProps = {
bootstrapData: {
currentUser: {
name: 'Philip J Fry',
age: 1_026,
},
},
};
return { props };
}
// This default export is the root component in a Next.js
// page. The props passed to this component come from the
// server via `getServerSideProps`.
export default function MyApp({ bootstrapData }: PageProps) {
return (
// We include our strap provider and give it the
// bootstrap data. This initializes data and make it
// immediately available for use via strap hooks, e.g.
// `useCurrentUser`.
<MyStrapProvider bootstrapData={bootstrapData}>
<MyComponent />
</MyStrapProvider>
);
}
function MyComponent() {
// We use the hook created above, which makes sure that
// we're calling this hook in a component with
// <MyStrapProvider> as a parent in the component tree.
const currentUser = useCurrentUser();
return (
// Prints "Hello Philip J Fry"
<div>Hello {currentUser.name}</div>
);
}
Multi-page Apps
React Strapped is designed specifically for csMPAs, which React Strapped supports via multiple Provider components. You can have as many providers as you want with any amount of nesting. All hooks associated with providers are available for use, regardless of where that provider sits in relation to other providers.
This nesting works differently than in Recoil and Jotai. In Jotai, you can only use atom values associated with the closest Provider
. In Recoil, you can either only access the root-most RecoilRoot
, or the closest RecoilRoot
if the override
property is specified for that root. Neither of these allow you to access all values from all providers, regardless of nesting.
Why does this distinction matter? In multi-page applications, we often have a set of bootstrap data that is common to all pages as well as bootstrap data that is specific to a page. The natural setup to handle these two sets of data is to create one provider for the common bootstrap data that exists on all pages, and then per-page providers that contain just those pages' data. React Strapped supports exactly this provider setup by allowing any number of straps to be nested, and propagating data appropriately between them.
In code, we set this up like so:
export default function MyPage({
commonBootstrapData,
myPageBootstrapData
}) {
return (
// Add the common provider, which is used on all pages
<MyCommonStrapProvider
bootstrapData={commonBootstrapData}
>
{/* SomeCommonComponents only has access to hooks from
MyCommonStrapProvider */}
<SomeCommonComponents />
{/* Add the page specific provider here, which is only
used on this page */}
<MyPageSpecificStrapProvider
bootstrapData={myPageBootstrapData}
>
{/* SomePageSpecificComponents has access to all
hooks from both MyCommonStrapProvider _and_
MyPageSpecificStrapProvider */}
<SomePageSpecificComponents />
</MyPageSpecificStrapProvider>
</MyCommonStrapProvider>
)
}
With this setup, the SomePageSpecificComponents
component tree has access to all hooks associated with both the common and page specific straps, but the SomeCommonComponents
component tree only has access to hooks associated with the common strap.
If bootstrap data exists across two or more pages, but not all pages, you can create a third strap that is shared between these pages that sits between the common strap and the page specific strap.
To ensure that SomeCommonComponents
code cannot access the page specific hooks, these hooks provide guardrails against accessing data from the wrong place. If you try and call a hook based on MyPageSpecificStrapProvider
from SomeCommonComponents
, then you'll get an error saying you're trying to access it from the wrong place, like so:
Conclusion
And that's that. The library is very simple at only 138 lines of code with zero dependencies. I'm really excited about this approach because it:
- Is technically and conceptually very simple
- Removes a lot of boilerplate otherwise required to create safe access to bootstrap data
- Provides strong guardrails for how we access page-specific bootstrap data in a csMPA
I'd love to hear what you think about this library!
Footnotes
[1] Most of this content comes from the Motivation section of the React Strapped README.
[2] Most of this content comes from the Motivation section of the Recoil Bootstrap README, an early version of what eventually became Recoil Strapped.
[3] Most of this content comes from the Getting Started section of the React Strapped README.
Top comments (0)