• Fast Setup
  • Articles:
  • SSR part 1. Creating simple SSR application
  • SSR part 2. Migration legacy app to SSR
  • SSR part 3. Advanced Techniques
  • Log Driven Development
  • Localization. True way
Rockpack

SSR part 2. Migration legacy app to SSR

If you haven't read the first part of this article, it covers the basic concepts and ideas. I ask you to read it first and only then continue reading.

Of course, in real life, none of us will store important state in the local state of the component. For these purposes, we use different state management systems, such as Redux, Mobx and others. In this article I will consider an example of how to make an application with SSR support from an existing application using Redux, Redux-Saga.

As an example, we have Redux + Redux-Saga application that works with an API:

actions.js:

import { createAction } from '@reduxjs/toolkit';
export const fetchImage = createAction('The image will fetch');
export const requestImage = createAction('The image is fetching...');
export const requestImageSuccess = createAction('The image has already fetched');
export const requestImageError = createAction('The image fetched with error');

reducer.js:

import { createReducer } from '@reduxjs/toolkit';
import { requestImage, requestImageSuccess, requestImageError } from './actions';
export default createReducer({
url: '',
loading: false,
error: false,
}, {
[requestImage.type]: () => ({
url: '',
loading: true,
error: false,
}),
[requestImageSuccess.type]: (state, { payload }) => ({
url: payload.url,
loading: false,
error: false,
}),
[requestImageError.type]: () => ({
url: '',
loading: false,
error: true,
})
});

saga.js:

import { call, put, takeEvery } from 'redux-saga/effects';
import { fetchImage, requestImage, requestImageSuccess, requestImageError } from './actions';
function* watchFetchImage() {
yield takeEvery(fetchImage, fetchImageAsync);
}
function* fetchImageAsync(rest) {
try {
yield put(requestImage());
const { data } = yield call(() => fetch('https://picsum.photos/id/0/info').then(d => d.json()));
yield put(requestImageSuccess({ url: data.download_url }));
} catch (error) {
yield put(requestImageError());
}
}
export default watchFetchImage;

Container.jsx:

import React, { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { fetchImage } from './action';
const App = () => {
const dispatch = useDispatch();
const image = useSelector(state => state.imageReducer);
useEffect(() => {
dispatch(fetchImage());
}, []);
return (
<div>
{image.loading ?
<p>Loading...</p> : image.error ?
<p>Error, try again</p> : (
<p>
<img width="200px" alt="random" src={image.url} />
</p>
)}
</div>
);
};
export default App;

Everything is pretty clear. We make a request. We put the loading state, when the response arrives, we extract the data as payload, put it in the reducer, render. If an error occurs, set the error flag.

store.js:

import { configureStore, getDefaultMiddleware } from '@reduxjs/toolkit';
import createSagaMiddleware from 'redux-saga';
import { fork } from 'redux-saga/effects';
import imageReducer from './reducer';
import watchFetchImage from './saga';
export default () => {
const middleware = getDefaultMiddleware({
immutableCheck: true,
serializableCheck: true,
thunk: false,
});
const sagaMiddleware = createSagaMiddleware();
const store = configureStore({
reducer: {
imageReducer
},
middleware: middleware.concat([
sagaMiddleware
])
});
function* sagas() {
yield fork(watchFetchImage);
}
const rootSaga = sagaMiddleware.run(sagas);
return { store, rootSaga };
};

index.jsx:

import React from 'react';
import { render } from 'react-dom';
import { Provider } from 'react-redux';
import createStore from './store';
import App from './Container';
const { store } = createStore();
render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
);

Let's start migration to the SSR!

Let's start with the same steps as in the first part of the article:

1. Installation

npm install @rockpack/ussr --save
npm install @rockpack/compiler --save-dev

Let's create a build.js file at the root of our project. It will allow us to compile our client and server, processing TS, JSX, various resources such as SVG, images, and more.

const { isomorphicCompiler, backendCompiler, frontendCompiler } = require('@rockpack/compiler');
isomorphicCompiler(
frontendCompiler({
src: 'src/client.jsx',
dist: 'public',
}),
backendCompiler({
src: 'src/server.jsx',
dist: 'dist',
})
);

Launch commands:

cross-env NODE_ENV=development node build.js
cross-env NODE_ENV=production node build.js

The general logic is not necessary, we will rename our index.jsx to client.jsx and createserver.jsx:

import React from 'react';
import express from 'express';
import { serverRender } from '@rockpack/ussr';
import { App } from './App';
const app = express();
app.use(express.static('public'));
app.get('/*', async (req, res) => {
const { store } = createStore();
const { html } = await serverRender(() => (
<Provider store={store}>
<App />
</Provider>
));
res.send(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<div id="root">${html}</div>
</body>
</html>
`);
});
app.listen(4000, () => {
console.log('Example app listening on port 4000!');
});

Redux, Saga -> SSR

We have finished preparing, now we have gone step by step.

Change in Container.jsx

useEffect(() => {
dispatch(fetchImage());
}, []);
to this:
useUssrEffect(() => {
dispatch(fetchImage());
});

useUssrEffect is a method from @rockpack/ussr. See the first part of the article.

Modify store.js to receive synchronized state:

export default ({ initState }) => {
const middleware = getDefaultMiddleware({
immutableCheck: true,
serializableCheck: true,
thunk: false,
});
const sagaMiddleware = createSagaMiddleware();
const store = configureStore({
reducer: {
imageReducer
},
middleware: middleware.concat([
sagaMiddleware
]),
preloadedState: initState
});
function* sagas() {
yield fork(watchFetchImage);
}
const rootSaga = sagaMiddleware.run(sagas);
return { store, rootSaga };
};

Modifying server.jsx

import React from 'react';
import express from 'express';
import { END } from 'redux-saga';
import serialize from 'serialize-javascript';
import { serverRender } from '@rockpack/ussr';
import { App } from './App';
const app = express();
app.use(express.static('public'));
app.get('/*', async (req, res) => {
const { store, rootSaga } = createStore({
initState: { }
});
const { html } = await serverRender(() => (
<Provider store={store}>
<App />
</Provider>
), async () => {
store.dispatch(END);
await rootSaga.toPromise();
});
ctx.body = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<script>
window.REDUX_DATA = ${serialize(store.getState(), { isJSON: true })}
</script>
</head>
<body>
<div id="root">${html}</div>
<script src="/index.js"></script>
</body>
</html>
`);
});
app.listen(4000, () => {
console.log('Example app listening on port 4000!');
});

What's going on here?

1. We create store with default state as empty object:

const { store, rootSaga } = createStore({
initState: { }
});

2. @rockpack/ussr - serverRender method can take a function as its second argument. It will be called when an asynchronous operation is detected in order to execute all side effects from the outside. In solutions such as Redux, Apollo and other asynzronic operations we perform not at the React level, but, as in this case, at the saga level. In this case. we need a tool to wait for them outside.

const { html } = await serverRender(() => (
<Provider store={store}>
<App />
</Provider>
), async () => {
store.dispatch(END);
await rootSaga.toPromise();
});

Specifically in this code, we have - render the application, @rockpack/ussr understands that in the depths of our code there is a side effect, but in this case, it is not asynchronous

useUssrEffect(() => {
dispatch(fetchImage());
});

Therefore, a callback will be called in which we will perform an asynchronous operation at the sag level, since this is not part of the React Components

store.dispatch(END);
await rootSaga.toPromise();

3. After that, we transfer the resulting state to the client, but we take it from the redux store

window.REDUX_DATA = ${serialize(store.getState(), { isJSON: true })}

In client.jsx, we need to change the creation of the store, setting the state that came from the backend for synchronization

const { store } = createStore(
initState: window.REDUX_DATA
});

Conclusion

We have ported our Redux Application to SSR. In the same way, we can process any asynchronous operations from the outside, for example, an Apollo application can be easily transferred to SSR according to this scenario. A collection of examples can be found here.

License MIT, 2020