Simple full-stack data loading for React

React Frontload is a library to load and manage data inline in React components that works on both client and server.

These are the (just shipped) v2 docs! See here for the motivation for v2 and comparison with v1
Install
npm install react-frontload
Docs

What problem does this solve?

#

React provides no built-in way to do data loading - it's left for you to implement. Doing this is tricky in a React app that uses server side rendering (SSR) because client and server rendering work quite differently: Client render is async so data can be loaded inside components when they render, but server render is completely synchronous - the data must be loaded before render happens.

Data loading is, of course, async. The client component-centric data loading pattern is nice, but it's incompatible with synchronous server render. React simply has no mechanism to wait for data to load when components render on SSR. There's a further problem too: React also provides no built-in way to hydrate data loaded during SSR into client state on first render. This is also up to you to implement.

So, full stack data loading in React is a tricky problem. A couple of solutions have emerged:

  1. Load data at the route level, instead of the component level, then pass data to all components under the route. Works on SSR since the route is known in the request, so data can be loaded before render begins. Can be implemented by piecing together router and state manager libraries.

  2. Use a framework that wraps React and abstracts the problem away, perhaps by providing a framework-specific async data loading function for components that works on SSR, and also takes care of hydrating state on the client.

React Frontload aims to provide a third way - the component-centric data loading pattern available full stack, but without having to buy into a whole framework just to get this feature. It's just a small library that solves this one problem, and can be used in any React stack.

An example

#

Here's an example of loading data into a component with React Frontload

const Component = () => { const { data, frontloadMeta } = useFrontload('my-component', async ({ api }) => ({ stuff: await api.getStuff() })) if (frontloadMeta.pending) return <div>loading</div> if (frontloadMeta.error) return <div>error</div> return <div>{data.stuff}</div> }

Here we have a Component that needs to load stuff from an API, with the usual loading state whilst it loads and some sort of error state if loading fails for some reason.

With React Frontload, we do this by passing an async data loading function to the useFrontload hook. The hook loads the return value of the function into data, and gives us frontloadMetadata out the box so we can see when it's still pending or if an error is thrown when running the function.

That's it - we're done in those few lines of code. That's the power of doing data loading inline in a component. And the best part here is that this just works on the server. If we render Component in a route, any route, stuff will load.

To emphasise the ease and lack of plumbing involved in making changes, let's have Component load some more stuff:

const Component = () => { const { data, frontloadMeta } = useFrontload('my-component', async ({ api }) => ({ stuff: await api.getStuff(), moreStuff: await api.getMoreStuff() // just add this })) if (frontloadMeta.pending) return <div>loading</div> if (frontloadMeta.error) return <div>error</div> return <div>{data.stuff} and {data.moreStuff}</div> // and use it }

It's literally that simple - just add whatever you need to useFrontload and use it. Remember that this is all automatically typed. If the value returned by the getMoreStuff api call is astring, data.moreStuff has the string type, and you'll get errors if you try to use it as a number.

You may have noticed that the above code loads data less efficiently than it could. api.getStuff() and api.getMoreStuff() are called in serial, when they could probably be called in parallel. Since it's just Javascript, though, we can change this:

const { data, frontloadMeta } = useFrontload('my-component', async ({ api }) => { const [stuff, moreStuff] = await Promise.all([ api.getStuff(), api.getMoreStuff() ]) return { stuff, moreStuff } })

In fact as data loaders get more complex, you can use any combination of serial and parallel that you need. It's just Javascript - you have the full power of the language without any abstractions or misdirection on top.

There is one more piece to this - what about updating data? Since React Frontload uses React component state to hold data, updating it is just a case of updating that state. React Frontload provides another function for this called setData

const Component = () => { const { data, setData, frontloadMeta } = useFrontload('my-component', async ({ api }) => ({ stuff: await api.getStuff() })) if (frontloadMeta.pending) return <div>loading</div> if (frontloadMeta.error) return <div>error</div> const updateStuff = async () => { try { const updatedStuff = await updateStuff('new value') // API call setData(data => ({ ...data, stuff: updatedStuff })) // update data in state } catch { // handle error } } return ( <> <div>{data.stuff}</div> <button onClick={updateStuff}>update</button> <> )

Again, this is all just inline in the component with zero plumbing. If you're coming from Redux, you can think of setData a little bit like a mini reducer. It takes the existing value of data as an argument, and you merge updates into it to return an updated value of data.

And that's it - full stack data loading and management inline in your React components.

Setup

#

The useEffect hook seen in the example above is the core of React Frontload - the code you'll actually work with in your components - but there is also a small amount of one-time setup code to write to get it to work.

Essentially this is setting up wrappers around your server render logic, and your React application on both server and client, to make theuseFrontload hook work, and also enable hydration of state loaded on server render to the client.

App Provider

Wrap your app in the React Frontload provider

import { FrontloadProvider } from 'react-frontload' const App = ({ frontloadState }) => ( <FrontloadProvider initialState={frontloadState}> <Content>...</Content> </FrontloadProvider> )

Server render

On server render, you need to wrap your existing synchronous server render code with reactFrontloadServerRender.

You can think of this as the polyfill that allows React Frontload to load data asynchronously on server render. It just uses regular React server rendering under the hood, and its output is exactly the same. Read more about this here.

import { renderToString } from 'react-dom/server' import { createFrontloadState, frontloadServerRender } from 'react-frontload' import serverApi from './serverApi' app.get('*', async (req, res) => { ... // create a new state object for each render // this will eventually be passed to the FrontloadProvider in <App> const frontloadState = createFrontloadState.server({ // inject server impl of api for use in data loading functions. // might make SQL queries directly instead of making HTTP calls if those // endpoints are on this same server context: { api: serverApi } }) try { // frontloadServerRender is of course async - all data is loaded before the final output is rendered const { rendered, data } = await frontloadServerRender({ frontloadState, render: () => renderToString(<App frontloadState={frontloadState} />) }) res.send(` <html> ... <!-- server rendered markup --> ${rendered} <!-- loaded data (to be hydrated on client) --> <script>window._frontloadData=${toSanitizedJSON(data)}</script> ... </html> `) } catch (err) { // handle errors } })

The output of reactFrontloadServerRender contains a rendered string, which is just the server render output to inject into your HTML template as usual.

It also contains a data object, which you should serialize into your HTML as sanitised JSON. data contains all the data loaded for the current view across all components, and the purpose of this is to hydrate this data into React Frontload on the client (using initialState) so that it does not have to be reloaded on first render. This pattern is essentially the same as the one you see with other state managers such as Redux.

Client render

The last step is client integration, which is simpler. Just initialise a React Frontload state object on the client using your serialized data from server render, and pass it to the provider.

import clientApi from './clientApi' const frontloadState = createFrontloadState.client({ // inject client impl of api for use in data loading functions. // will probably make HTTP calls to the server context: { api: clientApi }, // hydrate state from SSR serverRenderedData: window._frontloadData }) ... ReactDOM.render(<App frontloadState={frontloadState} />, ...)

How does it work?

#

For most usecases, you don't need to care about this.

That said, all abstractions are leaky at some point, and it's always useful to understand how things work under the hood so that when they behave unexpectedly, you can figure out why.

The mechanism used to polyfill async on server render is deliberately very simple. As shown in the code above, React Frontload wraps ordinary synchronous server render code with an async function.

It works by running that synchronous function, and collecting the promises encountered on each render of a useFrontload hook. After the render, the collected promises are then awaited, which loads the data in those functions the same as it would be on the client. Now, React Frontload runs the server render again - this time injecting the data loaded from the previous run into each component ahead of the render. In this new render round, if no new useFrontload hooks are encountered (i.e. if there are no nested components with a useFrontload hook), then the output of this render is returned as the final output. If nested useFrontload hooks are found, the process repeats.

API reference

#

useFrontload is the React Frontload hook.

import { useFrontload } from 'react-frontload' useFrontload( key: string, fn: (context: any) => Promise<T>, ) => { data: T, setData: ((data: DataType) => T) => T, frontloadMetadata: { pending: boolean done: boolean error: any serverRendered: boolean } }

It takes 2 args, a unique string key for the component (similar to the React component key, in fact the same value could be used), and a data loading fn which returns aPromise of data (of type T).

fn has one arg injected by React Frontload when called - the React Frontload context. This is a constant which is set up in the frontloadState which you pass to the FrontloadProvider. This is a convenient way to do dependency injection into your data loading function. A common example would be providing different implementations of an API client on client and server render.

// with a hardcoded API client, you must use same implementation on client and server import { api } from './api' useFrontload('my-component', async () => { await api.getStuff() }) - - - - - // with an API client in context, you can provide different implementations // on client and server, via the frontloadState prop in <FrontloadProvider> useFrontload('my-component', async (context) => { await context.api.getStuff() })

The return value of the hook contains a few properties:

data - the loaded data of type T. On the client, this is undefined until the data loads)

setData - a function to update data state. It uses React state under the hood, so is asynchronous, and takes a single arg - a function to which the current value of data is provided and from which the updated value of data should be returned.

frontloadMetadata - an object containing pending (true while data is loading),done (true when data has loaded), serverRendered (true when this component was server rendered). error is populated with any error thrown by fn on the client.

An important thing to understand is that on server render, only data is relevant. Your model on server render should be that fn always runs before render, there are no errors, and therefore data is available (and the fields of frontloadMetadata are constants - pending is false, done is true, serverRendered is of course true and error is undefined).

If an error does throw on server render, it is not caught and populated in frontloadMetadata.error, instead it is just thrown and the entire render will fail. Such errors should be caught at the top level in your server render endpoint, and dealt with appropriately for your application.

FrontloadProvider is how you plug React Frontload in to your actual application. Like any provider, you simply need to wrap it around your app at the top level.

frontloadState is an object you create once on the client, and also once on each server render, and pass to FrontloadProvider. It contains context and configuration.

React Frontload provides utility functions for creating frontloadState on both client and server, so this setup is simple.

import { FrontloadProvider, createFrontloadState } from 'react-frontload' // client createFrontloadState.client({ serverRenderedData: object, context?: any, logging?: boolean }) // server createFrontloadState.server({ context?: any, logging?: boolean }) // both: pass created frontloadState to <FrontloadProvider> <FrontloadProvider initialState={frontloadState}> ... </FrontloadProvider>

frontloadServerRender is the wrapper function that makes async data loading work on React server render.

import { frontloadServerRender, createFrontloadState } from 'react-frontload' frontloadServerRender({ frontloadState: FrontloadState, render: ({ isFinalRender?: boolean }) => string }) => Promise<{ rendered: string, data: object }>

It's an async function and takes an argument with 2 fields: frontloadServerRender, which is created on every server render and also passed to FrontloadProvider, and a render function which should contain your regular synchronous React server render logic - returning the rendered markup.

The render function has an isFinalRender property injected at runtime which indicates if this is the final render pass (React Frontload polyfills async server render by running the render function repeatedly until all promises are collected, see the how does it work section above for more detail). This is useful if you need to render stylesheets or other one-time side effects only after the final render - this is a common pattern with style libraries.

The return value is a Promise (the function is, of course, async) of the output of the final render, and also all the data loaded from all useFrontload hooks encountered in the render. This data must then be serialised so it can be hydrated into frontloadState on the client. See the example section for an example of this.