How to use useEffect on server-side?
What a bummer — useEffect
does not work on the server-side 😱. When I finally set up SSR (server-side rendering) in my project I noticed that there is no data from external APIs in the source of my page. This seems legit — useEffect
runs after component render and there would be another re-render after it fetches data. However, on the server-side, there is only one render cycle. No updates. No re-renders.
I came up with a simple solution:
Just render your application twice 🕶
And I did. This is how.
The plan
- Render the application on the server for the first time.
- Collect all effects.
- Execute effects and wait for the API responses.
- Render the application for the second time, this time with data.
A simple SSR project
Below is the simplest SSR example I managed to come up with. For now, focus on three files: Server.jsx
, Client.jsx
, App.jsx
. It follows a common pattern in all SSR projects:
Server.jsx
is an entry point on the server. It usesrenderToNodeStream
method to render the React app inside the Node.js server.Client.jsx
is an entry point in the browser. Notice that instead ofrender
we usehydrate
method.App.js
is a common file for both environments and it is the actual React app.
If you have any problems with this embed just visit this CodeSandbox project.
All my data fetching is placed in the useEffect
hook in App.jsx
.
I encourage you to open the browser preview in a new window and lurk into the page source. The app indeed rendered on server-side — we see that the h1
was injected into the page source. However, there is no data from the API:
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"/></head>
<body>
<div id="app">
<div data-reactroot="">
<h1>Adventure Time</h1>
</div>
</div>
</body>
<script src="/static/Client.js"></script>
</html>
Make it work ✨
First, we need a way to communicate between components and node.js server during rendering. We are going to use Context
— it will allow us to pass effects to the server and send data back to components.
The final app can be found in this CodeSandbox. If you scroll down there is an embed of this sandbox.
Context
Create a new file Context.jsx
import React from "react";const Context = React.createContext({});export default Context;
To pass data using context, the application must be wrapped by Context.Provider
. This will be done in Server.jsx
and Client.jsx
.
Server
In the request handler in Server.jsx
we use renderToString
to invoke first render of application. Application is wrapped by our Context.Provider
with default data {requests: []}
. This is where we will collect all effects.
After renderToString
is finished we can wait for all effects to resolve. Since requests
array is not needed anymore it can be deleted.
const contextValue = { requests: [] };renderToString(
<Context.Provider value={contextValue}>
<App />
</Context.Provider>
);await Promise.all(contextValue.requests);
delete contextValue.requests;
In this moment contextValue
is filled up with data from effects (I will explain in a moment how that happened). In the next step, we inject into HTML a <script>
tag containing JSON of all data. We will use it later in Client.jsx
.
Finally, we render our application for the second time using renderToNodeStream
method. The application is wrapped by Context.Provider
however this time with all data.
res.write(
`<script>window.initialData = ${JSON.stringify(contextValue)};</script>`
);const htmlStream = renderToNodeStream(
<Context.Provider value={contextValue}>
<App />
</Context.Provider>
);
Client
As on the server-side, in the client-side inClient.jsx
file we need to wrap our application in Context.Provider
. The initial value of context is grabbed from the global variable window.initialData
.
let value = {};
if (window && window.initialData) {
value = window.initialData;
}hydrate(
<Context.Provider value={value}>
<App />
</Context.Provider>,
document.getElementById("app")
);
This takes us to App.jsx
.
const App = () => {
const [people] = useServerEffect([], "people", () => {
return fetch("https://adventuretimeapi.herokuapp.com/people")
.then(res =>
res.json()
);
});return (
<div>
<h1>Adventure Time</h1>
{people.map(person => (
<div>{person.fullname}</div>
))}
</div>
);
};
Notice, that we no longer use useState
and useEffect
hooks. Instead, we use a custom hook useServerEffect
. Remember that fetch
function preset inside of the effect function will be executed both on the server-side and the client-side. This is why we need to use something like isomorphic-fetch
— an npm package that will allow us to use fetch in Node.js.
use(Server)Effect hook
This is the whole code of our custom hook. Let’s take a look…
import React, { useState, useContext } from "react";
import Context from "./Context";const useServerEffect = (initial, key, effect) => {
const context = useContext(Context);
const [data] = useState(context[key] || initial); if (context.requests) {
context.requests.push(
effect().then(data => (context[key] = data))
);
} return [data];
};export default useServerEffect;
It takes three arguments:
initial
— this is an initial state, just like inuseState
.key
— after the effect resolves, data is saved in our context under this key.effect
— our effect function, it must return aPromise
.
Notice that we use built-inuseContext
to access our context.
Remember that on the server we render our application twice? This is what happens in our hook during these renders:
1️⃣During the first render, our context looks like this { requests: [] }
. There is no data yet, only an empty request array. This is when we grab our effect and save it to context. Notice that when it resolves, it puts data in the context under the key
.
2️⃣During the second render, the context is already filled up with data. There is also no request array. useState
takes that data and sets up the state which later is returned to component.
This is the whole example. If you look into page source this time you will notice that all data fetched from API is present. Magic 🌈
If you liked this solution perhaps you will be interested in using useSSE
— a custom react hook available as an npm package.
use-sse
This npm package makes it easier to use and has some additional features. Hope you will enjoy it :)
If you have any questions or issues write a comment here or go to GitHub page of this project.