We render the React app JSX to HTML on the server instead of the client's browser.
const app = express();
app.use(express.static("./build", { index: false }));
app.get("/*", async (req, res) => {
const reactApp = renderToString(
<StaticRouter location={req.url}>
<App />
</StaticRouter>
);
const templateFile = path.resolve("./build/index.html");
fs.readFile(templateFile, "utf8", (err, data) => {
if (err) {
return res.status(500).send(err);
}
res.send(
data.replace('<div id="root"></div>', `<div id="root">${reactApp}</div>`)
);
});
});
npx babel-node server.js
or
npx nodemon --exec npx babel-node server.js
We'll use babel to run our server and transpile code.
.babelrc
- @babel/preset-env: standard prest for converting newer javascript syntax into older javascript syntax
- @babel/preset-react: preset for transpiling the JSX into actual javascript code
-
When an api is used, the server renders the frontend except for the parts where we need to load data.
-
The API gets called after the app has been loaded from server. We want to avoid this second trip.
-
i.e. the server should load this data instead of the frontend.
If we create an API route for loading data in the server, we will find that this data is loaded after the server has rendered the app.
app.get("/api/articles", (req, res) => {
...
res.json(loadedArticles);
});
- Send the data into the html window using
<script>
tags.
app.get("/*", (req, res) => {
...
res.send(
data.replace(
'<div id="root"></div>',
`<script>window.preloadedArticles = ${JSON.stringify(loadedArticles)}</script><div id="root">${reactApp}</div>`
));
});
- To load the data into the app body from the server-side, we need to use context to communicate between the frontend and the backend.
- This is because the
useEffect
hook will not be called when the components are being rendered on the server side.
Render our app on the server side twice:
- First Render: We'll find what components need to load their data on the server side.
- Second Render: And then, load that data on our server and pass it back through the context provider.
- Create an object
contextObj
and pass it to frontend throughContext.Provider
.
const contextObj = {
_isServerSide: true, // frontend will check: If true, they will push their data requests in `_requests`
_requests: [], // get data requests from frontend on first render
_data: {}, // will contain the requested data
};
renderToString(
<InitialDataContext.Provider value={contextObj}>
... <App /> ...
</InitialDataContext.Provider>
);
- After the first render, we resolve all requests and get ready for final render.
await Promise.all(contextObj._requests);
contextObj._isServerSide = false;
delete contextObj._requests;
renderToString(
<InitialDataContext.Provider value={contextObj}>
... <App /> ...
</InitialDataContext.Provider>
);
- Create a custom hook for fetching data on the frontend side.
const useDataSSR = (resourceName, loadFunc) => {
const context = useContext(InitialDataContext);
const [data, setData] = useState(context._data[resourceName]); // CASE 1
if (context._isServerSide) {
// CASE 2
context._requests.push(
loadFunc().then((result) => {
context._data[resourceName] = result;
})
);
} else if (!data) {
// CASE 3
loadFunc().then((result) => {
setData(result);
context._data[resourceName] = result;
});
}
return data;
};
Let's cover all cases.
CASE 1: Render #2 + Data is available
Data gets loaded from the context `_data` and is returned to the component calling it.
const [data, setData] = useState(context._data[resourceName]);
CASE 2: Render #1
- We push the load function to `_requests`. - Add a `.then()` function to make sure that the result is stored in `_data` when the load function is executed.
if (context._isServerSide) {
context._requests.push(
loadFunc().then((result) => {
context._data[resourceName] = result;
})
);
}
CASE 3: Render #2 + Data is not available
- We execute the load function and return result as data. - We also store the result in `_data` to make it available for later.
else if (!data) {
loadFunc().then((result) => {
setData(result);
context._data[resourceName] = result;
});
}
In Articles.js
const articles = useDataSSR("articles", () => {
console.log("No preloaded articles found, loading from server.");
return fetch("http://localhost:8080/api/articles").then((res) => res.json());
});
Note:
- Since the server side has to render the frontend, we need
fetch
to work on the server. It only exists by default in the browser. - This is why we import an implementation of fetch in our backend.
npm install isomorphic-fetch
import "isomorphic-fetch";
- We need the complete server URL for this:
http://localhost:8080/api/articles
- Instead of delivering all React code to the client at once, we deliver it in pieces as needed.
- This maximizes performance by letting us reduce the amount of code that the client side has to load on the first render.
- Create
lazy
components:
const One = lazy(() => import("../components/One"));
const Two = lazy(() => import("../components/Two"));
const Three = lazy(() => import("../components/Three"));
Note: Here, the components have to be exported by default for the import statements to work.
- Rendering them with
Suspense
:
<Suspense fallback={<p>Loading components...</p>}>
<One />
<Two />
<Three />
</Suspense>
- Whenever there is a large portion of the code that users will not be seeing in one go.
- Generally, splitting is based on the pages/ components that the users view together.
- For example: Route pages, components that appear on a button click.
-
Lazy loading introduces new error possibilities for our application.
-
We are relying on the network to load our components. So, if we have a network problem, our components could run into errors and our application could crash.
-
Error Boundaries are basically components that block off sections of the user interface that we expect may cause some kind of error.
class ErrorBoundary extends Component {
constructor(props) {
super(props);
this.state = {
error: null,
};
}
componentDidCatch(error, errorInfo) {
console.log({ error, errorInfo });
}
static getDerivedStateFromError(error) {
return { error };
}
render() {
if (this.state.error) {
return <p>Uh Oh! Something went wrong.</p>;
}
return this.props.children;
}
}
<Suspense fallback={<p>Loading components...</p>}>
<ErrorBoundary>
<One />
</ErrorBoundary>
...
</Suspense>
If your app is crashing because of the error, you'll need production build.
npm run build
npm install -g serve
serve -s build
- Function Based: The highest level folders in the src directory are based on the functions they provide.
src
└───hooks
└───network
└───pages
└───reducers
└───util
- Feature Based: The highest level folders in the src directory are based on the features they are used in.
- This works better for large scale applications where different developers work on different features.
src
└───articles
└───sign-ups
└───subscriptions
- All the project code is included in a single codebase.
- It generally has to be modified and deployed all at once.
- Simple at first. Usually the default.
- Can become unmanageable very quickly.
- Ideal for very small teams working on short-term projects.
- The project code is separated into multiple codebases.
- Each codebase can be worked on and deployed independently.
- Add some overhead for setup.
- Make the deployment process more complex.
- Allo independent versioning of different parts.
- Generally better for companies with fairly isolated teams.
- Mix of both.
- Single codebase + Organised such that each piece is largely independent.
- Many same benefits as multi-repos, except code is technically in the same repo.
- Used by large tech companies, including Google, Microsoft, Twitter.
- LinkedIn Learning Course - Link