create-react-app-ssr
This package provides a couple of useful tools to take your CRA to the next
level and work out a fully fledged modern universal PWA.
NOTE: this is an holistic approach based on years of attempts to find a
decent developer experience with the teams I've been workign on.
I try my best to step out the way and write as little custom code as possible,
leveraging as much as I can on existing good and widely used libraries, but
this is my personal point of view of what a modern Javascript web appication
should look like.
You may like it, you may not.
Here I propose some step by step tutorials that take a CRA 2.x
end enrich it
with the good libraries that we already know, taking away the wiring from you
(and myself) because I like to focus on business values instead of the
infrastructure.
who am I kidding? I love working on the infrastructure! But at one point I
also have to step into some "getting things done" mood, and when I do I would
like to have stuff that "just works", this thing "just darn works".
Folder Structure
All the things that we are going to try to do together originates from a
CRA 2.x
app, so all the folder structures and relative paths that you are
going to see in this document are based on you doing:
npx create-react-app my-app
So the way I suggest you to think about your application folder strategy is something like this:
- /public
- /src
- /app
- /features
- /commponents
- /ssr
- /features
- /services
- /models
- /routes
- /mutations
- /queries
Add a redux state to your app
In this tutorial we take the basic CRA structure and add:
- redux state
- features support
barebone state
Create /src/app/state.js
and write the basics:
import { createAppStateFactory} from "create-react-app-ssr/lib/create-app-state-factory"
export const createState = createAppStateFactory()
here we are using the createAppStateFactory
utility that generates a function that has the final resposability to create the state for your app.
Why all this s**t?
But for SSR compatibility of course!
When you will run this app on the server you need a clojure around your state manager so multiple concurrent requests that run async interlaced pieces of logic will not leak data into each other!
render your app with state
In /src/index.js
we now need to implement this state with our app:
import { createState } from "./app/state"
createState()
.then(props => ReactDOM.render(<App {...props} />, document.getElementById('root')))
.catch(err => console.log(err))
createState
will provide our state clojure. It is an asynchronous function because it will boot up all the features that we might like to add to our app (we will get there.)
![basic state](https://github.com/marcopeg/create-react-app-ssr/raw/HEAD/./images/create-react-app-ssr__basic-state.png)
The problem, you already know, is that we need to wrap our App
with a redux provider so to be able to access the state and do cool stuff. Our next step is to create a /src/app/Root.js
file that serves as main entry point and general wrapper of our business logic:
/* eslint react/prop-types: off */
import React from "react"
import { Provider } from "react-redux"
import App from "../App"
const Root = ({ store, ...props }) => (
<Provider store={store}>
<App {...props} />
</Provider>
)
export default Root
and then change our /src/index.js
so that it uses Root
instead of App
:
import { createState } from "./app/state"
import { Root } from "./app/Root"
createState()
.then(props => ReactDOM.render(<Root {...props} />, document.getElementById('root')))
.catch(err => console.log(err))
So far so good, if everything is still working you have now a CRA that is also provided with a redux store.
We didn't add any reducer so far, but if you investigate the state with the dev tools (which are set up by default) you may notice a ssr
state property. This was injected by create-react-app-ssr
and has some useful stuff to help you deal with backend requests and server side rendering of asynchronous components (we will get to it).
Anyway, we can use it to test if our app is correctly connected. Let's modify the basic App.js
component and create a super simple connected component (aka container) that shows something from the state:
import React from "react"
import { connect } from "react-redux"
const mapState = ({ ssr }) => ({
title: "My test app",
rootUrl: ssr.getRootUrl(""),
})
const App = ({ title, rootUrl }) => (
<div>
<h4>{title}</h4>
<code>rootUrl: {rootUrl}</code>
</div>
)
export default connect(mapState)(App)
The expected result is something similar to this screenshot:
![basic state](https://github.com/marcopeg/create-react-app-ssr/raw/HEAD/./images/create-react-app-ssr__basic-state1.png)
Add features to your app
The idea here is to encapsulate parts of your development efforts into isolated folders. You and I should be able to work on 2 different features without fear of stepping onto each other's shoes. It doesn't matter how good we are ar resolving git conflicts, avoiding conflicts saves time.
Let's first create an empty feature and register it into our state manager, just to get the structure of it out of our way:
Create the feature scaffold
In /src/features/users/index.js
paste:
export const reducers = {}
export const services = []
export const listeners = []
And in /src/features/index.js
paste:
export default [
require("./users"),
]
Last mechanical step is to add the features into our state manager creator, open /src/app/state.js
and change it to:
import { createAppStateFactory} from "create-react-app-ssr/lib/create-app-state-factory"
import features from "../features"
export const createState = createAppStateFactory({}, features)
If everything works... you shouldn't see anything... because we simply registered an empty feature that does nothing. But you should also see
no errors in the console. In the next step we are going to add our first reducer to a feature
Add your first feature reducer
Our feature is named "users" so it's likely handling users. Let's add a "users" reducer that help us managing this kind of data.
Create /src/features/users/users.reducer.js
:
export const initialState = {
list: [],
}
/**
* Actions
*/
export const ADD_USER = "addUser@users"
export const addUser = user => ({
type: ADD_USER,
payload: user,
})
/**
* Handlers
*/
export const actionHandlers = {
[ADD_USER]: (state, { payload }) => ({
...state,
list: [ ...state.list, payload ],
})
}
export const reducer = (state = initialState, action) => {
const handler = actionHandlers[action.type]
return handler ? handler(state, action) : state
}
export default reducer
And register it into the feature's manifest /src/features/users/index.js
:
export const reducers = {
users: require("./users.reducer").default,
}
If you reload you app (well, it should reload by herself) you should finally be able to see "users" as key of your app's state in the redux dev tools!
![basic state](https://github.com/marcopeg/create-react-app-ssr/raw/HEAD/./images/create-react-app-ssr__basic-state2.png)
Add your first feature service
A service is just a nice name for asynchronous redux actions. Services are basically collections of thunks, other pieces of the application import those functions and dispatch them (probably from a connected component).
The main thing about a feature's service is that you can export 2 particular functions, init()
and start()
that allow you to hook into the lifecycle of your application. All the init()
functions will be executed and only after that all the start()
. This will give you a predictable behaviour that you can use to setup the stage for your services.
IMPORTANT - SSR: I've seen many times people using the module's scope to save variables that are later on used in different functions. This behaviour works great in the client, because I have my browser and you have yours, but it's a very different story when your app is server side rendered. Keep in mind that the only scope that you can play with is the redux state. If you need local data to be persisted across different functions, use a reducer.
Our first service will simulate a backend load action to retrieve a bounch of users. Create /src/features/users/users.service.js
:
import { addUser } from "./users.reducer"
// this thunk will be used to retreive stuff from the server
// so far is just dummy data, we will move it to the backend.
export const fetchUsers = () => () => {
const dummyData = [{
id: 1,
username: "Luke Skywalker",
}, {
id: 2,
username: "Ian Solo",
}]
return new Promise((resolve) => {
setTimeout(() => resolve(dummyData), 1000)
})
}
// fetch data and deals with the reducer
// here we should also take care of error handling
export const loadUsers = () => async (dispatch) => {
const users = await dispatch(fetchUsers())
users.map(user => dispatch(addUser(user)))
}
// hook into life cycle and load users when the application boot
export const start = () => (dispatch) => {
dispatch(loadUsers())
}
At this point we only need to register the service and we will be able to "load users" when the application boot. Edit /src/features/users/index.js
with the proper change:
export const services = [
require("./users.service"),
]
Events and Listeners
Now is the time to complete the data management part of your feature with some pub/sub capabilities. Let's say that when the users are loaded for the first time, we want to notify the rest of the application, so that other features can do something with it.
You know already that we will simply dispatch a redux action, but I suggest you keep good track of this actions in a specific file called /src/features/users/events.js
:
export const USERS_FIRST_LOAD = "@@USERS::FIRSTLOAD"
export const usersFirstLoad = (usersList) => ({
type: USERS_FIRST_LOAD,
payload: usersList,
})
This file will list all the redux events that your feature is capable of emitting. Consider it just anothe simple manifest file, something that will improve the quality of your codebase and reduce the amount of strings that you need to copy all over the place.
Now let's modify the loadUsers
so to identify a first load and emit this event in /src/features/users/users.service.js
:
import { usersFirstLoad } from "./events"
...
export const loadUsers = () => async (dispatch, getState) => {
const isFirstLoad = getState().users.list.length === 0
const users = await dispatch(fetchUsers())
users.map(user => dispatch(addUser(user)))
// dispatch an event that is meant for other features to react to
if (isFirstLoad) dispatch(usersFirstLoad(users))
}
NOTE: a redux event is just a normal redux action to which we choose to give a particular meaning, nothing more than that :-)
Your second feature - a loading curtain
In order to tackle the listening and reacting to events it's more efficient to try to add a new cool feature to our app. A loading curtain has the scope of masquerading the UI while some initial operations are taking place.
We are going to something extremely simple, we will write a reducer, a container and a listener. But in order to start try to use what you learned so far and scaffold a curtain
feature.
The curtain reducer
This is going to be trivial. A loading curtain is a piece of UI that need to be visible or not, based on some conditions. So in this reducer we are simply going to store the visibility value. Create /src/features/curtain/curtain.reducer.js
and register it to your feature:
export const initialState = true
/**
* Actions
*/
export const SET_VALUE = "setValue@curtain"
export const setValue = value => ({
type: SET_VALUE,
payload: Boolean(value),
})
/**
* Handlers
*/
export const actionHandlers = {
[SET_VALUE]: (state, { payload }) => payload
}
export const reducer = (state = initialState, action) => {
const handler = actionHandlers[action.type]
return handler ? handler(state, action) : state
}
export default reducer
The curtain container
This piece of UI simply need to cover everything and link it's visibility to the current curtain state value. Create /src/features/curtain/Curtain.js
:
import React from "react"
import { connect } from "react-redux"
const mapState = ({ curtain }) => ({
isVisible: curtain,
})
const style = {
position: "fixed", top: 0, bottom: 0, left: 0, right: 0,
display: "flex", justifyContent: "center", alignItems: "center",
backgroundColor: "#258cf9", color: "#fff",
}
const Curtain = ({ isVisible }) => isVisible
? <div style={style}>loading...</div>
: null
export default connect(mapState)(Curtain)
Now, in order to keep good control over our feature system, I suggest you export every feature capability from the feature's index.js
itself. Edit /src/features/curtain/index.js
and add:
export { default as Curtain } from "./Curtain"
so that we can import it from the /src/App.js
and drop the Curtain
UI into the main app:
import { Curtain } from "./features/curtain"
...
const App = ({ title, rootUrl }) => (
<div>
<h4>{title}</h4>
<code>rootUrl: {rootUrl}</code>
<Curtain />
</div>
)
![add the curtain](https://github.com/marcopeg/create-react-app-ssr/raw/HEAD/./images/create-react-app-ssr__curtain.png)
The curtain Listener
Finally we are getting to the point of having a listener! What we want to achieve is that after the users first load, the curtain will go away.
Create the listener file in /src/features/curtain/curtain.listener.js
:
import { setValue } from "./curtain.reducer"
export default [
{
type: "@@USERS::FIRSTLOAD",
handler: (action) => (dispatch) => {
console.log("An action was dispatched", action)
dispatch(setValue(false))
},
},
]
And register it into the feature's manifest /src/features/curtain/index.js
:
export const listeners = [
require("./curtain.listener").default,
]
This should be enough to get the Curtain to disappear after the fake loading time of 1s that we set in the users' service.