A complete solution for building a React/Redux application
<title/>
, <meta/>
, social network sharing)$ yarn add redux react-redux
or:
$ npm install redux react-redux --save
Then, install react-pages
:
$ yarn add react-pages
or:
$ npm install react-pages --save
react-pages
configuration file.The configuration file:
import routes from './routes.js'
export default {
routes
}
The routes
:
import App from '../pages/App.js'
import Item from '../pages/Item.js'
import Items from '../pages/Items.js'
export default [{
Component: App,
path: '/',
children: [
{ Component: App },
{ Component: Items, path: 'items' },
{ Component: Item, path: 'items/:id' }
]
}]
The page components:
import React from 'react'
import { Link } from 'react-pages'
export default ({ children }) => (
<section>
<header>
Web Application
</header>
{children}
<footer>
Copyright
</footer>
</section>
)
import React from 'react'
export default () => <div> This is the list of items </div>
import React from 'react'
export default ({ params }) => <div> Item #{params.id} </div>
render()
in the main client-side javascript file of the app.The main client-side javascript file of the app:
import { render } from 'react-pages/client'
import settings from './react-pages.js'
// Render the page in a web browser.
render(settings)
The index.html
file of the app usually looks like this:
<html>
<head>
<title>Example</title>
<!-- Fix encoding. -->
<meta charset="utf-8">
<!-- Fix document width for mobile devices. -->
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
<script src="https://github.com/catamphetamine/react-pages/raw/master/bundle.js"></script>
</body>
</html>
Where bundle.js
is the ./src/index.js
file built with Webpack (or you could use any other javascript bundler).
The index.html
and bundle.js
files must be served over HTTP(S).
If you're using Webpack then add HtmlWebpackPlugin
to generate index.html
, and run webpack-dev-server
with historyApiFallback
to serve the generated index.html
and bundle.js
files over HTTP on localhost:8080
.
HtmlWebpackPlugin
configuration exampleOr see the Webpack example project.
If you're using Parcel instead of Webpack then see the basic example project for the setup required in order to generate and serve index.html
and bundle.js
files over HTTP on localhost:1234
.
So now the website should be fully working.
The website (index.html
, bundle.js
, CSS stylesheets and images, etc) can now be deployed as-is in a cloud (e.g. on Amazon S3) and served statically for a very low price. The API can be hosted "serverlessly" in a cloud (e.g. Amazon Lambda) which is also considered cheap. No running Node.js server is required.
Yes, it's not a Server-Side Rendered approach because a user is given a blank page first, then bundle.js
script is loaded by the web browser, then bundle.js
script is executed fetching some data from the API via an HTTP request, and only when that HTTP request comes back — only then the page is rendered (in the browser). Google won't index such websites, but if searchability is not a requirement (at all or yet) then that would be the way to go (e.g. startup "MVP"s or "internal applications"). Server-Side Rendering can be easily added to such setup should the need arise.
This concludes the introductory part of the README and the rest is the description of the various tools and techniques which come prepackaged with this library.
A working example illustrating Server-Side Rendering and all other things can be found here: webpack-react-redux-isomorphic-render-example.
Another minimalistic example using Parcel instead of Webpack can be found here: react-pages-basic-example.
react-pages
configuration file supports a rootComponent
parameter. It should be the root component of the application. It receives properties: children
and store
(Redux store).
The default (and minimal) rootComponent
is simply a Redux Provider
wrapped around the children
. The Redux Provider
enables Redux, because this library uses Redux internally.
import { Provider as ReduxProvider } from 'react-redux'
export default function DefaultRootComponent({ store, children }) {
return (
<ReduxProvider store={store}>
{children}
</ReduxProvider>
)
}
If you plan on using Redux in your application, provide a reducers
object in the react-pages
configuration file.
import routes from './routes.js'
// The `reducers` parameter should be an object containing
// Redux reducers that will be combined into a single Redux reducer
// using the standard `combineReducers()` function of Redux.
import * as reducers from './redux/index.js'
export default {
routes,
reducers
}
Where the reducers
object should be:
// For those who're unfamiliar with Redux concepts,
// a "reducer" is a function `(state, action) => state`.
//
// The main (or "root") "reducer" usually consists of "sub-reducers",
// in which case it's an object rather than a function,
// and each property of such object is a "sub-reducer" function.
//
// There's no official name for "sub-reducer".
// For example, Redux Toolkit [calls](https://redux.js.org/usage/structuring-reducers/splitting-reducer-logic) them "slices".
//
export { default as subReducer1 } from './subReducer1.js'
export { default as subReducer2 } from './subReducer2.js'
...
To add custom Redux "middleware", specify a reduxMiddleware
parameter in the react-pages
configuration file.
export default {
...,
// `reduxMiddleware` should be an array of custom Redux middlewares.
reduxMiddleware: [
middleware1,
middleware2
]
}
To "load" a page before it's rendered (both on server side and on client side), define a static load
property function on the page component.
The load
function receives a "utility" object as its only argument:
function Page({ data }) {
return (
<div>
{data}
</div>
)
}
Page.load = async (utility) => {
const {
// Can `dispatch()` Redux actions.
dispatch,
// Can be used to get a slice of Redux state.
useSelector,
// (optional)
//
// "Load Context" could hold any custom developer-defined variables
// that could then be accessed inside `.load()` functions.
//
// To define a "load context":
//
// * Pass `getLoadContext()` function as an option to the client-side `render()` function.
// The options are the second argument of that function.
// The result of the function will be passed to each `load()` function as `context` parameter.
// The result of the function will be reused within the scope of a given web browser tab,
// i.e. `getLoadContext()` function will only be called once for a given web browser tab.
//
// * (if also using server-side rendering)
// Pass `getLoadContext()` function as an option to the server-side `webpageServer()` function.
// The options are the second argument of that function.
// The result of the function will be passed to each `load()` function as `context` parameter.
// The result of the function will be reused within the scope of a given HTTP request,
// i.e. `getLoadContext()` function will only be called once for a given HTTP request.
//
// `getLoadContext()` function recevies an argument object: `{ dispatch }`.
// `getLoadContext()` function should return a "load context" object.
//
// Miscellaneous: `context` parameter will also be passed to `onPageRendered()`/`onBeforeNavigate()` functions.
//
context,
// (optional)
// A `context` parameter could be passed to the functions
// returned from `useNavigation()` hooks. When passed, that parameter
// will be available inside the `.load()` function of the page as `navigationContext` parameter.
navigationContext,
// Current page location (object).
location,
// Route URL parameters.
// For example, for route "/users/:id" and URL "/users/barackobama",
// `params` will be `{ id: "barackobama" }`.
params,
// Navigation history.
// Each entry is an object having properties:
// * `route: string` — Example: "/user/:userId/post/:postId".
// * `action: string` — One of: "start", "push", "redirect", "back", "forward".
history,
// Is this server-side rendering?
server,
// (utility)
// Returns a cookie value by name.
getCookie
} = utility
// Send HTTP request and wait for response.
// For example, it could just be using the standard `fetch()` function.
const data = await fetch(`https://data-source.com/data/${params.id}`)
// Optionally return an object containing page component `props`.
// If returned, these props will be available in the page component,
// same way it works in Next.js in its `getServerSideProps()` function.
return {
// `data` prop will be available in the page component.
props: {
data
}
}
}
The load
property function could additionally be defined on the application's root React component. In that case, the application would first execute the load
function of the application's root React component, and then, after it finishes, it would proceed to executing the page component's load
function. This behavior allows the root React component's load
function to perform the "initialization" of the application: for example, it could authenticate the user.
load
functionload
functionload
indication (during navigation).load
indication (initial).On client side, in order for load
to work, all links must be created using the <Link/>
component imported from react-pages
package. Upon a click on a <Link/>
, first it waits for the next page to load, and then, when the next page is fully loaded, the navigation itself takes place.
load
also works for Back/Forward navigation. To disable page load
on Back navigation, pass instantBack
property to a <Link/>
.Fetching data in an application could be done using several approaches:
fetch()
for making HTTP requests and then storing the result in React Component state using useState()
hook setter.fetch()
for making HTTP requests and then storing the result in Redux state by dispatch()
-ing a "setter" action.Implementing synchronous actions in Redux is straightforward. But what about asynchronous actions like HTTP requests? Redux itself doesn't provide any built-in solution for that leaving it to 3rd party middlewares. Therefore this library provides one.
This is the lowest-level approach to asynchronous actions. It is described here just for academic purposes and most likely won't be used directly in any app.
If a Redux "action creator" returns an object with a promise
(function) and events
(array) then dispatch()
ing such an action results in the following steps:
type = events[0]
is dispatchedpromise
function gets called and returns a Promise
Promise
succeeds then an event of type = events[1]
is dispatched having result
property set to the Promise
resultPromise
fails then an event of type = events[2]
is dispatched having error
property set to the Promise
errorfunction asynchronousAction() {
return {
promise: () => Promise.resolve({ success: true }),
events: ['PROMISE_PENDING', 'PROMISE_SUCCESS', 'PROMISE_ERROR']
}
}
dispatch(asynchronousAction())
call returns the Promise
itself:
Page.load = async ({ dispatch }) => {
await dispatch(asynchronousAction())
}
Because in almost all cases dispatching an "asynchronous action" in practice means "making an HTTP request", the promise
function used in asynchronousAction()
s always receives an { http }
argument: promise: ({ http }) => ...
.
The http
utility has the following methods:
head
get
post
put
patch
delete
Each of these methods returns a Promise
and takes three arguments:
url
of the HTTP requestdata
object (e.g. HTTP GET query
or HTTP POST body
)options
(described further)So, API endpoints can be queried using http
and ES6 async/await
syntax like so:
function fetchFriends(personId, gender) {
return {
promise: ({ http }) => http.get(`/api/person/${personId}/friends`, { gender }),
events: ['GET_FRIENDS_PENDING', 'GET_FRIENDS_SUCCESS', 'GET_FRIENDS_FAILURE']
}
}
The possible options
(the third argument of all http
methods) are
headers
— HTTP Headers JSON object.authentication
— Set to false
to disable sending the authentication token as part of the HTTP request. Set to a String to pass it as an Authorization: Bearer ${token}
token (no need to supply the token explicitly for every http
method call, it is supposed to be set globally, see below).progress(percent, event)
— Use for tracking HTTP request progress (e.g. file upload).onResponseHeaders(headers)
– Use for examining HTTP response headers (e.g. Amazon S3 file upload).Content-Type
Once one starts writing a lot of promise
/http
Redux actions it becomes obvious that there's a lot of copy-pasting and verbosity involved. To reduce those tremendous amounts of copy-pasta "redux module" tool may be used which:
http
.${actionName}_PENDING
, ${actionName}_SUCCESS
, ${actionName}_ERROR
).${actionName}Pending
: true
/false
, ${actionName}Error: Error
) in Redux state.For example, the fetchFriends()
action from the previous section can be rewritten as:
Before:
// ./actions/friends.js
function fetchFriends(personId, gender) {
return {
promise: ({ http }) => http.get(`/api/person/${personId}/friends`, { gender }),
events: ['FETCH_FRIENDS_PENDING', 'FETCH_FRIENDS_SUCCESS', 'FETCH_FRIENDS_FAILURE']
}
}
// ./reducers/friends.js
export default function(state = {}, action = {}) {
switch (action.type) {
case 'FETCH_FRIENDS_PENDING':
return {
...state,
fetchFriendsPending: true,
fetchFriendsError: null
}
case 'FETCH_FRIENDS_SUCCESS':
return {
...state,
fetchFriendsPending: false,
friends: action.value
}
case 'FETCH_FRIENDS_ERROR':
return {
...state,
fetchFriendsPending: false,
fetchFriendsError: action.error
}
default
return state
}
}
After:
import { ReduxModule } from 'react-pages'
const redux = new ReduxModule('FRIENDS')
export const fetchFriends = redux.action(
'FETCH_FRIENDS',
(personId, gender) => http => {
return http.get(`/api/person/${personId}/friends`, { gender })
},
// The fetched friends list will be placed
// into the `friends` Redux state property.
'friends'
//
// Or write it like this:
// { friends: result => result }
//
// Or write it as a Redux reducer:
// (state, result) => ({ ...state, friends: result })
)
// This is the Redux reducer which now
// handles the asynchronous action defined above.
export default redux.reducer()
Much cleaner.
Also, when the namespace or the action name argument is omitted it is autogenerated, so this
const redux = new ReduxModule('FRIENDS')
...
redux.action('FETCH_ITEM', id => http => http.get(`/items/${id}`), 'item')
could be written as
const redux = new ReduxModule()
...
redux.action(id => http => http.get(`/items/${id}`), 'item')
and in this case redux
will autogenerate the namespace and the action name, something like REACT_WEBSITE_12345
and REACT_WEBSITE_ACTION_12345
.
redux.on()
"
//
redux.on('BLOG_POST', 'CUSTOM_EVENT', (state, action) => ({
...state,
reduxStateProperty: action.value
}))
// This is the Redux reducer which now
// handles the asynchronous actions defined above
// (and also the `handler.on()` events).
// Export it as part of the "main" reducer.
export default redux.reducer()
```
#### redux/index.js
```js
// The "main" reducer is composed of various reducers.
export { default as blogPost } from './blogPost'
...
```
The React Component would look like this
```js
import React from 'react'
import { getBlogPost, getComments, postComment } from './redux/blogPost'
export default function BlogPostPage() {
const userId = useSelector(state => state.user.id)
const blogPost = useSelector(state => state.blogPost.blogPost)
const comments = useSelector(state => state.blogPost.comments)
return (
export const action = redux.simpleAction()
redux.on()
To enable sending and receiving cookies when making cross-domain HTTP requests, specify http.useCrossDomainCookies()
function in react-pages.js
configuration file. If that function returns true
, then it has the same effect as changing credentials: "same-origin"
to credentials: "include"
in a fetch()
call.
When enabling cross-domain cookies on front end, don't forget to make the relevant backend changes:
Access-Control-Allow-Origin
HTTP header from *
to an explict comma-separated list of the allowed domain names.Access-Control-Allow-Credentials: true
HTTP header.{
http: {
// Allows sending cookies to and receiving cookies from
// "trusted.com" domain or any of its sub-domains.
useCrossDomainCookies({ getDomain, belongsToDomain, url, originalUrl }) {
return belongsToDomain('trusted.com')
}
}
}
In order to send an authentication token in the form of an Authorization: Bearer ${token}
HTTP header, specify http.authentication.accessToken()
function in react-pages.js
configuration file.
{
http: {
authentication: {
// If a token is returned from this function, it gets sent as
// `Authorization: Bearer {token}` HTTP header.
accessToken({ useSelector, getCookie }) {
return localStorage.getItem('accessToken')
}
}
}
}
This library doesn't force one to dispatch "asynchronous" Redux actions using the http
utility in order to fetch data over HTTP. For example, one could use the standard fetch()
function instead. But if one chooses to use the http
utility, default error handlers for it could be set up.
To listen for http
errors, one may specify two functions in react-pages.js
configuration file:
onLoadError()
— Catches all errors thrown from page load()
functions.http.onError()
— Catches all HTTP errors that weren't thrown from load()
functions. Should return true
if the error has been handled successfully and shouldn't be printed to the console.{
http: {
// (optional)
// Catches all HTTP errors that weren't thrown from `load()` functions.
onError(error, { url, location, redirect, dispatch, useSelector }) {
if (error.status === 401) {
redirect('/not-authenticated')
// `return true` indicates that the error has been handled by the developer
// and it shouldn't be re-thrown as an "Unhandled rejection".
return true
} else {
// Ignore the error.
}
},
// (optional)
// (advanced)
//
// Creates a Redux state `error` property from an HTTP `Error` instance.
//
// By default, returns whatever JSON data was returned in the HTTP response,
// if any, and adds a couple of properties to it:
//
// * `message: string` — `error.message`.
// * `status: number?` — The HTTP response status. May be `undefined` if no response was received.
//
getErrorData(error) {
return { ... }
}
}
}
http
utility it is recommended to set up http.transformUrl(url)
configuration setting to make the code a bit cleaner.The http
utility will also upload files if they're passed as part of data
(see example below). The files passed inside data
must have one of the following types:
File
it will be a single file upload.FileList
with a single File
inside it would be treated as a single File
.FileList
with multiple File
s inside a multiple file upload will be performed.<input type="file"/>
DOM element all its .files
will be taken as a FileList
parameter.File upload progress can be metered by passing progress
option as part of the options
.
By default, when using http
utility all JSON responses get parsed for javascript Date
s which are then automatically converted from String
s to Date
s.
This has been a very convenient feature that is also safe in almost all cases because such date String
s have to be in a very specific ISO format in order to get parsed (year-month-dayThours:minutes:seconds[timezone]
, e.g. 2017-12-22T23:03:48.912Z
).
Looking at this feature now, I wouldn't advise enabling it because it could potentially lead to a bug when it accidentally mistakes a string for a date. For example, some user could write a comment with the comment content being an ISO date string. If, when fetching that comment from the server, the application automatically finds and converts the comment text from a string to a Date
instance, it will likely lead to a bug when the application attempts to access any string-specific methods of such Date
instance, resulting in a possible crash of the application.
Therefore, currenly I'd advise setting http.findAndConvertIsoDateStringsToDateInstances
flag to false
in react-pages.js
settings file to opt out of this feature.
{
...
http: {
...
findAndConvertIsoDateStringsToDateInstances: false
}
}
Server-Side Rendering is good for search engine indexing but it's also heavy on CPU not to mention the bother of setting up a Node.js server itself and keeping it running.
In many cases data on a website is "static" (doesn't change between redeployments), e.g. a personal blog or a portfolio website, so in these cases it will be beneficial (much cheaper and faster) to host a statically generated version a website on a CDN as opposed to hosting a Node.js application just for the purpose of real-time webpage rendering. In such cases one should generate a static version of the website by snapshotting it on a local machine and then host the snapshotted pages in a cloud (e.g. Amazon S3) for a very low price.
The snapshotting approach works not only for classical web "documents" (a blog, a book, a portfolio, a showcase) but also for dynamic applications. Consider an online education portal where users (students) can search for online courses and the prices are different for each user (student) based on their institution. Now, an online course description itself is static (must be indexed by Google) and the actual course price is dynamic (must not be indexed by Google).
load
s for the course page: one for static data (which runs while snapshotting) and another for dynamic data (which runs only in a user's web browser).To set a custom HTTP response status code for a specific route set the status
property of that route.
export default [{
path: '/',
Component: Application,
children: [
{ Component: Home },
{ path: 'blog', Component: Blog },
{ path: 'about', Component: About },
{ path: '*', Component: PageNotFound, status: 404 }
]
}]
<title/>
and <meta/>
tagsTo add <title/>
and <meta/>
tags to a page, define meta: (...) => object
static function on a page component:
function Page() {
return (
<section>
...
</section>
)
}
Page.load = async ({ params }) => {
return {
props: {
bodyBuilder: await getBodyBuilderInfo(params.id)
}
}
}
Page.meta = ({ props, useSelector, usePageStateSelector }) => {
const notificationsCount = useSelector(state => state.user.notificationsCount)
const { bodyBuilder } = props
return {
// `<meta property="og:site_name" .../>`
siteName: 'International Bodybuilders Club',
// Webpage `<title/>` will be replaced with this one
// and also `<meta property="og:title" .../>` will be added.
title: `(${notificationsCount}) ${bodyBuilder.name}`,
// `<meta property="og:description" .../>`
description: 'Muscles',
// `<meta property="og:image" .../>`
// https://iamturns.com/open-graph-image-size/
image: 'https://cdn.google.com/logo.png',
// Objects are expanded.
//
// `<meta property="og:image" content="https://cdn.google.com/logo.png"/>`
// `<meta property="og:image:width" content="100"/>`
// `<meta property="og:image:height" content="100"/>`
// `<meta property="og:image:type" content="image/png"/>`
//
image: {
_: 'https://cdn.google.com/logo.png',
width: 100,
height: 100,
type: 'image/png'
},
// Arrays are expanded (including arrays of objects).
image: [{...}, {...}, ...],
// `<meta property="og:audio" .../>`
audio: '...',
// `<meta property="og:video" .../>`
video: '...',
// `<meta property="og:locale" content="ru_RU"/>`
locale: state.user.locale,
// `<meta property="og:locale:alternate" content="en_US"/>`
// `<meta property="og:locale:alternate" content="fr_FR"/>`
locales: ['ru_RU', 'en_US', 'fr_FR'],
// `<meta property="og:url" .../>`
url: 'https://google.com/',
// `<meta property="og:type" .../>`
type: 'profile',
// `<meta charset="utf-8"/>` tag is added automatically.
// The default "utf-8" encoding can be changed
// by passing custom `charset` parameter.
charset: 'utf-16',
// `<meta name="viewport" content="width=device-width, initial-scale=1.0"/>`
// tag is added automatically
// (prevents downscaling on mobile devices).
// This default behaviour can be changed
// by passing custom `viewport` parameter.
viewport: '...',
// All other properties will be transformed directly to
// either `<meta property="{property_name}" content="{property_value}/>`
// or `<meta name="{property_name}" content="{property_value}/>`
}
})
The parameters of a meta
function are:
props
— Any props
returned from the load()
function.useSelector
— A hook that could be used to access Redux state.usePageSelector
— A hook that could be used to access "page-specific" Redux state.If the root route component also has a meta
function, the result of the page component's meta
function will be merged on top of the result of the root route component's meta
function.
The meta
will be applied on the web page and will overwrite any existing <meta/>
tags. For example, if there were any <meta/>
tags written by hand in index.html
template then all of them will be dicarded when this library applies its own meta
, so any "base" <meta/>
tags should be moved from the index.html
file to the root route component's meta
function:
function App({ children }) {
return (
<div>
{children}
</div>
)
}
App.meta = ({ useSelector }) => {
return {
siteName: 'WebApp',
description: 'A generic web application',
locale: 'en_US'
}
}
The meta
function behaves like a React "hook": <meta/>
tags will be updated if the values returned from useSelector()
function calls do change.
In some advanced cases, the meta()
function might need to access some state that is local to the page component and is not stored in global Redux state. That could be done by setting metaComponentProperty
property of a page component to true
and then rendering the <Meta/>
component manually inside the page component, where any properties passed to the <Meta/>
component will be available in the props
of the meta()
function.
function Page({ Meta }) {
const [number, setNumber] = useState(0)
return (
<>
<Meta number={number}/>
<button onClick={() => setNumber(number + 1)}>
Increment
</button>
</>
)
}
Page.metaComponentProperty = true
Page.meta = ({ props }) => {
return {
title: String(props.number)
}
}
If the application would like to listen to navigation changes — for example, to report the current location to Google Analytics — it might supply onPageRendered()
function option to the client-side render()
function:
onPageRendered()
function option only gets called after the navigation has finished. It also gets called at the initial page load when the user opens the website.
If navigation start events are of interest, one may supply onBeforeNavigate()
function option, which is basically the same as onPageRendered()
but runs before the navigation has started.
Inside a load
function: use the location
parameter.
Anywhere in a React component: use useLocation()
hook.
import { useLocation } from 'react-pages'
const location = useLocation()
One edge case is when an application is architectured in such a way that:
Component
handles a certain route.
Item
page component handles /items/:id
URLs./items/1
to /items/2
via a "Related items" links section.Component
has a .load()
function that puts data in Redux state.
Item
page component first fetch()
es item data and then puts it in Redux state via dispatch(setItem(itemData))
.Component
uses the loaded data from the Redux state.
Item
page component gets the item data via useSelector()
and renders it on the page.In the above example, when a user navigates from item A
to item B
, there's a short timeframe of inconsistency:
A
page renders item A
data from Redux state.B
.B
data is fetched and put into Redux state.A
page is still rendered. useSelector()
on it gets refreshed with the new data from Redux state and now returns item B
data while still being on item A
page.B
page is rendered. useSelector()
on it returns item B
data.In the steps above, there's a short window of data inconsistency at the step before the last one: the page component experiences a data update from item A
to item B
.
If the page component doesn't account for a possibility of such change, it may lead to tricky bugs. For example, each item could have a list of reviews and the page component can be showing one review at a time. To do that, the page component introduces its own local state — a shownReviewIndex
state variable — and then shows the review via <Review review={item.reviews[shownReviewIndex]}/>
. In such case, when a user navigates from item A
that has some reviews to item B
that has no reviews, the review
property value is gonna be undefined
which would break the <Review/>
component which would crash the whole page and display a blank screen to the user.
To work around such issues, any potentially unexpected updates to Redux state should be minimized inside page components. To do that, this package provides a parameter in the settings called pageStateReducerNames: string[]
and a set of two hooks that're meant to replace the standard useSelector()
hook for use in page components.
The standard route configuration usually has a "root" route and all other routes that branch out from it:
export default [{
path: "/",
Component: App,
children: [
{ path: 'not-found', Component: NotFound, status: 404 },
{ path: 'error', Component: Error, status: 500 },
...
]
}]
The pageStateReducerNames
parameter is specified in the settings and it should be a list of all Redux state keys that get modified from inside page .load()
functions:
export default {
routes,
reducers,
pageStateReducerNames: ['orderPage']
}
Those state keys become inaccessible via the standard useSelector()
hook and should be accessed via either usePageStateSelector()
hook or usePageStateSelectorOutsideOfPage()
hook, depending on where in the route component chain the hook is being called.
Using the hook somewhere inside a page component (or in its children):
import { usePageStateSelector } from 'react-pages'
export default function Page() {
// const order = useSelector(state => state.orderPage.order)
const order = usePageStateSelector('orderPage', state => state.orderPage.order)
...
}
Using the hook somewhere outside a page component:
export default function Page() {
// const order = useSelector(state => state.orderPage.order)
const order = usePageStateSelectorOutsideOfPage('orderPage', state => state.orderPage.order)
...
}
To access "page state" properties in page .meta()
functions, there's a parameter called usePageStateSelector
that work analogous to the usePageStateSelector()
exported hook.
useNavigationLocation
hook returns "navigation location" — the last location (so far) in the navigation chain:
Inside a load
function: you already know what route it is.
Anywhere in a React component: use useRoute()
hook.
import { useRoute } from 'react-pages'
const route = useRoute()
A route
has:
path
— Example: "/users/:id"
params
— Example: { id: "12345" }
location
— Same as useLocation()
To navigate to a different URL inside a React component, use useNavigation()
hook.
import { useNavigate, useRedirect } from 'react-pages'
// Usage example.
// * `navigate` navigates to a URL while adding a new entry in browsing history.
// * `redirect` does the same replacing the current entry in browsing history.
function Page() {
const navigate = useNavigate()
// const redirect = useRedirect()
const onClick = (event) => {
navigate('/items/1?color=red')
// redirect('/somewhere')
}
}
One could also pass a load: false
parameter in options
when calling navigate(location, options)
or redirect(location, options)
to skip the .load()
function of the target page.
One could also pass a navigation
parameter in options
when calling navigate(location, options)
or redirect(location, options)
to pass an additional parameter called navigationContext
to the .load()
function of the target page.
If the current location URL needs to be updated while still staying at the same page (i.e. no navigation should take place), then instead of redirect(location, options)
one should call locationHistory.replace(location)
.
import { useLocationHistory } from 'react-pages'
function Page() {
const locationHistory = useLocationHistory()
// * `locationHistory.push(location)`
// * `locationHistory.replace(location)`
// * `locationHistory.go(-1)`
const onSearch = (searchQuery) => {
dispatch(
locationHistory.replace({
pathname: '/'
query: {
searchQuery
}
})
)
}
return (
<input onChange={onSearch}/>
)
}
To go "Back" or "Forward", one could use useGoBack()
or useGoForward()
hooks.
import { useLocationHistory } from 'react-pages'
function Page() {
const goBack = useGoBack()
const goForward = useGoForward()
return (
<button onClick={() => goBack()}>
Back
</button>
)
}
Both goBack()
and goForward()
functions accept an optional delta
numeric argument that tells how far should it "go" in terms of the number of entries in the history. The default delta
is 1
.
If someone prefers to interact with found
router
directly then it could be accessed at any page: either as a router
property of a page component or via useRouter
hook.
import React from 'react'
import { useRouter } from 'react-pages'
export default function Component() {
const { match, router } = useRouter()
...
}
In places where React hooks can't be used, there're dispatch()
-able action creator alternatives to each navigation type. Those action creators are exported from this package: import { goto } from "react-pages"
.
dispatch(goto())
→ useNavigate()()
dispatch(redirect())
→ useRedirect()()
dispatch(pushLocation())
→ useLocationHistory().push()
dispatch(replaceLocation())
→ useLocationHistory().replace()
dispatch(goBack())
→ useGoBack()()
dispatch(goBackTwoPages())
→ 2x
useGoBack()()
dispatch(goForward())
→ useGoForward()()
import {
// These hooks can only be used in "leaf" route components.
useBeforeNavigateToAnotherPage,
useBeforeRenderAnotherPage,
useAfterRenderedThisPage,
// These hooks can only be used in a "root" route component.
useBeforeRenderNewPage,
useAfterRenderedNewPage
} from 'react-pages'
function Page() {
useBeforeNavigateToAnotherPage(({ location, route, params, instantBack, navigationContext }) => {
// Navigation to another page is about to start.
// It will start `.load()`ing another page.
// This is an appropriate time to snapshot the current page state.
})
useBeforeRenderAnotherPage(({ location, route, params, instantBack, navigationContext }) => {
// Navigation to another page is about to conclude.
// That other page has already been `.load()`ed and is about to be rendered.
// The current page is about to be unmounted.
})
useAfterRenderedThisPage(({ location, route, params, instantBack, navigationContext }) => {
// This page is currently rendered on screen.
// Is triggered at the initial render of the app and then after each navigation.
})
return (
<section>
<h1>
Page Title
</h1>
</section>
)
}
function Root({ children }) {
useBeforeRenderNewPage((newPage, prevPage?) => {
// Will render a new page on screen.
//
// const { location, route, params, instantBack, navigationContext } = newPage
})
useAfterRenderedNewPage((newPage, prevPage?) => {
// Has rendered a new page on screen.
// The initial render of the app also counts as "after rendered new page".
//
// const { location, route, params, instantBack, navigationContext } = newPage
})
return (
<main>
{children}
</main>
)
}
For each page being rendered stats are reported if stats()
parameter is passed as part of the rendering service settings.
{
...
stats({ url, route, time: { load } }) {
if (load > 1000) { // in milliseconds
db.query('insert into server_side_rendering_stats ...')
}
}
}
The arguments for the stats()
function are:
url
— The requested URL (without the protocol://host:port
part)route
— The route path (e.g. /user/:userId/post/:postId
)time.load
— The time for executing all load
s.
Rendering a complex React page (having more than 1000 components) takes about 30ms (as of 2017).
Webpack's Hot Module Replacement (aka Hot Reload) provides the ability to "hot reload" React components.
To enable hot reload for React components, one could use a combination of react-refresh/babel
Babel plugn and react-refresh-webpack-plugin
Webpack plugin.
npm install @pmmmwh/react-refresh-webpack-plugin react-refresh --save-dev
{
"presets": [
"react",
["env", { modules: false }]
],
"plugins": [
// React "Fast Refresh".
"react-refresh/babel"
]
}
import ReactRefreshWebpackPlugin from '@pmmmwh/react-refresh-webpack-plugin'
export default {
mode: 'development',
...,
plugins: [
new ReactRefreshWebpackPlugin(),
...
]
}
Then start webpack-dev-server
.
webpack serve --hot --module-strict-export-presence --stats-errors --stats-error-details true --config path-to-webpack.config.js"
P.S.: Hot reload won't work for page component's load
/meta
functions, so when a load
/meta
function code is updated, the page has to be refreshed in order to observe the changes.
Webpack's Hot Module Replacement (aka Hot Reload) provides the ability to "hot reload" Redux reducers and Redux action creators.
Enabling "hot reload" for Redux reducers and Redux action creators is slightly more complex and requires some additional "hacky" code. The following line:
import * as reducers from './redux/reducers.js'
Should be replaced with:
import * as reducers from './redux/reducers.with-hot-reload.js'
And a new file called reducers.with-hot-reload.js
should be created:
import { updateReducers } from 'react-pages'
import * as reducers from './reducers.js'
export * from './reducers.js'
if (import.meta.webpackHot) {
import.meta.webpackHot.accept(['./reducers.js'], () => {
updateReducers(reducers)
})
}
And then add some additional code in the file that calls the client-side render()
function:
import { render } from 'react-pages/client'
import settings from './react-pages.js'
export default async function() {
const { enableHotReload } = await render(settings)
if (import.meta.webpackHot) {
enableHotReload()
}
}
websocket()
helper sets up a WebSocket connection.
import { render } from 'react-pages/client'
import websocket from 'react-pages/websocket'
render(settings).then(({ store }) => {
websocket({
host: 'localhost',
port: 80,
// secure: true,
store,
token: localStorage.getItem('token')
})
})
If token
parameter is specified then it will be sent as part of every message (providing support for user authentication).
If the application is being built with a bundler (most likely Webpack) and Server-Side Rendering is enabled then make sure to build the server-side code with the bundler too so that require()
calls for assets (images, styles, fonts, etc) inside React components don't break (see universal-webpack, for example).
Code splitting is supported. See README-CODE-SPLITTING
When server-side rendering is enabled, one can pass a getInitialState()
function as an option to the server-side rendering function.
That function should return an object — the initial Redux state — based on its parameters:
cookies
— Cookies JSON object.headers
— HTTP request headers JSON object.locales
— A list of locales parsed from Accept-Language
HTTP header and ordered by most-preferred ones first.For example, the application could set defaultLocale
initial state property based on the Accept-Language
HTTP header value, or it could set device
initial state property based on the User-Agent
HTTP header value.
Suppose there's a "forum" web application having <Thread/>
pages with URLs like /thread/:id
, and one thread could link to another thread. When a user navigates to a thread and clicks a link to another thread there, a navigation transition will start: the "current" thread page will still be rendered while the "new" thread page is loading. The issue is that both these URLs use the same Redux state subtree, so, after the "new" thread data has been loaded, but before the "new" thread page is rendered, the "current" thread page is gonna re-render with the updated Redux state subtree.
If a thread page doesn't use useState()
, then it wouldn't be an issue. But if it does, it could result in weird bugs. For example, if a <Thread/>
page had a fromIndex
state variable that would control the first shown comment index, then, when the "current" page is re-rendered with the updated Redux state subtree for the "new" thread, the fromIndex
might exceed the "new" thread's comments count resulting in an "out of bounds" exception and the page breaking.
To prevent such bugs, for all routes that could link to the same route, their page components should be rendered in a wrapper with a key
corresponding to all URL parameters:
function Thread() {
const [fromIndex, setFromIndex] = useState(0)
return ...
}
Thread.meta = ...
Thread.load = async ({ dispatch, params }) => {
await dispatch(loadThreadData(params.id))
}
// This is a workaround for cases when navigating from one thread
// to another thread in order to prevent bugs when the "new" thread data
// has already been loaded and updated in Redux state but the "old" thread
// page is still being rendered.
// https://github.com/4Catalyzer/found/issues/639#issuecomment-567084189
export default function Thread_() {
const thread = useSelector(state => state.thread.thread)
return <Thread key={thread.id}/>
}
Thread_.meta = Thread.meta
Thread_.load = Thread.load
At some point in time this README became huge so I extracted some less relevant parts of it into README-ADVANCED (including the list of all possible settings and options). If you're a first timer then just skip that one - you don't need it for sure.