react-pages
v0.8.6
Published
A simple toolkit for building a React/Redux application: routing, loading page data, fetching data over HTTP, (optional) server-side rendering, etc.
Downloads
1,717
Maintainers
Readme
react-pages
A complete solution for building a React/Redux application
- Routing
- Loading pages
- (optional) Code splitting
- (optional) Server-side rendering
- Fetching data
- Easier Redux
- Document metadata (
<title/>
,<meta/>
, social network sharing) - Webpack "hot reload"
- HTTP Cookies
- etc
Introduction
Getting started
First, install Redux.
$ 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
Then, create a react-pages
configuration file.
The configuration file:
./src/react-pages.js
import routes from './routes.js'
export default {
routes
}
The routes
:
./src/routes.js
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:
./src/pages/App.js
import React from 'react'
import { Link } from 'react-pages'
export default ({ children }) => (
<section>
<header>
Web Application
</header>
{children}
<footer>
Copyright
</footer>
</section>
)
./src/pages/Items.js
import React from 'react'
export default () => <div> This is the list of items </div>
./src/pages/Item.js
import React from 'react'
export default ({ params }) => <div> Item #{params.id} </div>
Finally, call render()
in the main client-side javascript file of the app.
The main client-side javascript file of the app:
./src/index.js
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="/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).
And make sure that the output files are accessible from a web browser.
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
.
webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin')
const buildOutputPath = '...'
const devServerPort = 8080 // Any port number.
module.exports = {
output: {
path: buildOutputPath,
publicPath: `http://localhost:${devServerPort}`,
...
},
...,
plugins: [
new HtmlWebpackPlugin({
template: 'src/index.html' // Path to `index.html` file.
}),
...
],
devServer: {
port: devServerPort,
contentBase: buildOutputPath,
historyApiFallback : true
}
}
src/index.html
<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>
<!-- HtmlWebpackPlugin will insert a <script> tag here. -->
</body>
</html>
webpack-dev-server --hot --config webpack.config.js
Or 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
.
Done
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.
Adding server-side rendering to the setup is quite simple, although I'd consider it an "advanced" topic.
While client-side rendering could be done entirely in a web browser, server-side rendering would require running a Node.js process somewhere in a cloud which slightly increases the complexity of the whole setup.
So in case of server-side rendering, index.html
file is being generated on-the-fly by a page rendering server (a Node.js process) for each incoming HTTP request, so the index.html
file that was used previously for client-side rendering may be deleted now as it's of no longer use.
A Node.js script for running a "rendering server" process would look like this:
./rendering-server.js
import webpageServer from 'react-pages/server'
import settings from './react-pages'
// Create webpage rendering server
const server = webpageServer(settings, {
// Pass `secure: true` flag to listen on `https://` rather than `http://`.
// secure: true,
// These are the URLs of the "static" javascript and CSS files
// which are injected into the resulting HTML webpage in the form of
// <script src="..."/> and <link rel="style" href="..."/> tags.
//
// The javascript file should be the javascript "bundle" of the website
// and the CSS file should be the CSS "bundle" of the website.
//
// P.S.: To inject other types of javascript or CSS files
// (for example, files of 3rd-party libraries),
// use a separate configuration parameter called `html`:
// https://gitlab.com/catamphetamine/react-pages/blob/master/README-ADVANCED.md#all-webpage-rendering-server-options)
//
assets() {
return {
// This should be the URL for the application's javascript bundle.
// In this case, the configuration assumes that the website is being run
// on `localhost` domain with "static file hosting" enabled for its files.
javascript: 'http://localhost:8080/bundle.js',
// (optional)
// This should be the URL for the application's CSS bundle.
style: 'http://localhost:8080/bundle.css'
}
}
})
// Start webpage rendering server on port 3000.
// Syntax: `server.listen(port, [host], [callback])`.
server.listen(3000, function(error) {
if (error) {
throw error
}
console.log(`Webpage rendering server is listening at http://localhost:3000`)
})
Run the rendering server:
$ npm install npx --global
$ npm install babel-cli
$ npx babel-node rendering-server.js
Now disable javascript in Chrome DevTools, go to localhost:3000
and the server should respond with a fully server-side-rendered page.
Conclusion
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.
Documentation
Root component
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>
)
}
Redux
If you plan on using Redux in your application, provide a reducers
object in the react-pages
configuration file.
./src/react-pages.js
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:
./src/redux/index.js
// 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'
...
Middleware
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
]
}
Loading pages
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.
To catch all errors originating in load()
functions, specify an onLoadError()
parameter in react-pages.js
settings file.
{
onLoadError: (error, { url, location, redirect, useSelector, server }) => {
redirect(`/error?url=${encodeURIComponent(url)}&error=${error.status}`)
}
}
To redirect from a load
function, return an object with redirect
property, similar to how it works in Next.js in its getServerSideProps()
function.
UserPage.load = async ({ params }) => {
const user = await fetch(`/api/users/${params.id}`)
if (user.wasDeleted) {
return {
redirect: {
url: '/not-found'
}
}
}
return {
props: {
user
}
}
}
To permanently redirect from one URL to another URL, specify a permanentRedirectTo
parameter on the "from" route.
{
path: '/old-path/:id',
permanentRedirectTo: '/new-path/:id'
}
While the application is performing a load
as a result of navigating to another page, a developer might prefer to show some kind of a loading indicator. Such loading indicator could be implemented as a React component that listens to the boolean
value returned from useLoading()
hook.
import { useLoading } from 'react-pages'
import LoadingIndicator from './LoadingIndicator.js'
export default function PageLoading() {
const isLoading = useLoading()
return (
<LoadingIndicator show={isLoading}/>
)
}
export default function App({ children }) {
return (
<div>
<PageLoading/>
{children}
</div>
)
}
Initial client-side (non-server-side) load
is different from client-side load
during navigation: during the initial client-side load
, the <App/>
element is not rendered yet. Therefore, while the application is performing an initial client-side load
, a blank screen is shown.
There're two possible workarounds for that:
- Perform the initial load on server side (not on client side).
- Show some kind of a loading indicator instead of a blank screen during the initial load.
To show a loading indicator instead of a blank screen during the initial load, one could specify some additional react-pages
configuration parameters:
InitialLoadComponent
— A React component that shows an initial page loading indicator. Receives properties:initial: true
— This is just a flag that is alwaystrue
.show: boolean
— Istrue
when the component should be shown. Isfalse
when the component should no longer be shown.- When
false
is passed, the component could either hide itself immediately or show some kind of a hiding animation (for example, fade out). The duration of such hiding animation should be passed asinitialLoadHideAnimationDuration: number
parameter (see below) so that the library knows when can it unmount theInitialLoadComponent
.
- When
hideAnimationDuration: number
— This is just a copy ofinitialLoadHideAnimationDuration: number
parameter (see below) for convenience.
initialLoadShowDelay: number
— When supplyingInitialLoadComponent
, one should also specify the delay before showing theInitialLoadComponent
. For example, such delay could be used to only showInitialLoadComponent
for initial loads that aren't fast enough. For "no delay", the value should be0
.initialLoadHideAnimationDuration: number
— When supplyingInitialLoadComponent
, one should also specify the duration of the hide animation ofInitialLoadComponent
, if it has a hide animation. If there's no hide animation, the value should be0
.
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.
For example, consider a search results page loading some data (could be search results themselves, could be anything else unrelated). A user navigates to this page, waits for load
to finish and then sees a list of items. Without instantBack
if the user clicks on an item he's taken to the item's page. Then the user clicks "Back" and is taken back to the search results page but has to wait for that load
again. With instantBack
though the "Back" transition occurs instantly without having to wait for that load
again. Same goes then for the reverse "Forward" navigation from the search results page back to the item's page, but that's just a small complementary feature. The main benefit is the instantaneous "Back" navigation creating a much better UX where a user can freely explore a list of results without getting penalized for it with a waiting period on each click.
import React from 'react'
import { useSelector } from 'react-redux'
import { Link } from 'react-pages'
function SearchResultsPage() {
const results = useSelector(state => state.searchPage.results)
return (
<ul>
{ results.map((item) => (
<li>
<Link to="/items/{item.id}" instantBack>
{item.name}
</Link>
</li>
))) }
</ul>
)
}
SearchResultsPage.load = async () => await fetchSomeData()
There's also instantBack: true
option that could be passed to navigate(location, options)
function which is returned from useNavigate()
hook. The behavior of the option is the same.
instantBack
is ignored when navigating to the same route: for example, if there's an <Article/>
page component having a <Link instantBack/>
to another <Article/>
then instantBack
is ignored — this feature was originally added for Redux because it made sense that way (in Redux there's only one slot for data of a route that gets rewritten every time the route is navigated to). For other data fetching frameworks like Relay I guess it would make sense to turn that off. Create an issue if that's the case.
One can also use the exported wasInstantNavigation()
function (on client side) to find out if the current page was navigated to "instantly". This can be used, for example, to restore a "state" of a widget on instant "Back" navigation so that it renders immediately with the previously cached "results" or something.
There's also a canGoBackInstantly()
function (on client side) that tells if the currently page can be navigated "Back" from instantly. This function can be used to render a custom "Go Back" button on a page only when an instant "Back" transition could be performed.
There's also a canGoForwardInstantly()
function (analogous to canGoBackInstantly()
).
Fetching Data
Fetching data in an application could be done using several approaches:
- Using
fetch()
for making HTTP requests and then storing the result in React Component state usinguseState()
hook setter. - Using
fetch()
for making HTTP requests and then storing the result in Redux state bydispatch()
-ing a "setter" action. - Using "asynchronous actions" framework provided by this library, which is described in detail in the next section of this document. This is the most sophisticated variant of the three and it comes with many useful features such as:
- Handling cookies
- CORS utilities
- Authentication utilities
- File upload progress support
- Persisting the result in Redux state
Asynchronous actions
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.
Pure Promises
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:
- An event of
type = events[0]
is dispatched promise
function gets called and returns aPromise
- If the
Promise
succeeds then an event oftype = events[1]
is dispatched havingresult
property set to thePromise
result - If the
Promise
fails then an event oftype = events[2]
is dispatched havingerror
property set to thePromise
error
function 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())
}
HTTP utility
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:
- the
url
of the HTTP request data
object (e.g. HTTP GETquery
or HTTP POSTbody
)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 tofalse
to disable sending the authentication token as part of the HTTP request. Set to a String to pass it as anAuthorization: Bearer ${token}
token (no need to supply the token explicitly for everyhttp
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).
For that use the http.onRequest(request, { url, originalUrl, useSelector })
setting in ./react-pages.js
where:
request
is asuperagent
request
that can be modified. For example, to set an HTTP header:request.set(headerName, headerValue)
.originalUrl
is the URL argument of thehttp
utility call.url
is theoriginalUrl
transformed byhttp.transformUrl()
settings function. If nohttp.transformUrl()
is configured thenurl
is the same as theoriginalUrl
.
Redux module
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:
- Gives access to
http
. - Autogenerates Redux action status events (
${actionName}_PENDING
,${actionName}_SUCCESS
,${actionName}_ERROR
). - Automatically adds Redux reducers for the action status events.
- Automatically populates the corresponding action status properties (
${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/blogPost.js
import { ReduxModule } from 'react-pages'
const redux = new ReduxModule('BLOG_POST')
// Post comment Redux "action creator"
export const postComment = redux.action(
// 'POST_COMMENT',
(userId, blogPostId, commentText) => async http => {
// The original action call looks like:
// `dispatch(postComment(1, 12345, 'bump'))`
return await http.post(`/blog/posts/${blogPostId}/comment`, {
userId: userId,
text: commentText
})
}
)
// Get comments Redux "action creator"
export const getComments = redux.action(
// 'GET_COMMENTS',
(blogPostId) => async http => {
return await http.get(`/blog/posts/${blogPostId}/comments`)
},
// The fetched comments will be placed
// into the `comments` Redux state property.
'comments'
//
// Or write it like this:
// { comments: result => result }
//
// Or write it as a Redux reducer:
// (state, result) => ({ ...state, comments: result })
)
// A developer can listen to any Redux event via
// `redux.on('EVENT_NAME', (state, action) => state)`.
//
// In this case, it listens to a "success" event of a `redux.action()`.
// There's a section in this document describing this feature in more detail:
// "Redux module can also listen for events from other redux modules via <code>redux.on()</code>"
//
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
// The "main" reducer is composed of various reducers.
export { default as blogPost } from './blogPost'
...
The React Component would look like this
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 (
<div>
<article>
{ blogPost.text }
</article>
<ul>
{ comments.map(comment => <li>{comment}</li>) }
</ul>
<button onClick={() => postComment(userId, blogPost.id, 'text')}>
Post comment
</button>
</div>
)
}
// Load blog post and comments before showing the page
// (see "Page loading" section of this document)
BlogPostPage.load = async ({ dispatch, params }) => {
// `params` are the URL parameters in route `path`.
// For example, "/blog/:blogPostId".
await dispatch(getBlogPost(params.blogPostId))
await dispatch(getComments(params.blogPostId))
}
A simple Redux action that simply updates Redux state.
action = redux.simpleAction((state, actionArgument) => newState)
import { ReduxModule } from 'react-pages'
const redux = new ReduxModule('NOTIFICATIONS')
// Displays a notification.
//
// The Redux "action" creator is gonna be:
//
// function(text) {
// return {
// type : 'NOTIFICATIONS:NOTIFY',
// message : formatMessage(text)
// }
// }
//
// And the corresponding reducer is gonna be:
//
// case 'NOTIFICATIONS:NOTIFY':
// return {
// ...state,
// message: action.message
// }
//
// Call it as `dispatch(notify(text))`.
//
export const notify = redux.simpleAction(
// (optional) Redux event name.
'NOTIFY',
// The Redux reducer:
(state, message) => ({ ...state, message }),
// The Redux reducer above could be also defined as:
// 'message'
)
// This is the Redux reducer which now
// handles the actions defined above.
export default redux.reducer()
dispatch(notify('Test'))
// A developer can listen to any Redux event via
// `redux.on('EVENT_NAME', (state, action) => state)`.
//
// If one string argument is passed then it will listen for
// an exact Redux `action.type`.
//
// If two string arguments are passed then the first argument should be
// a `ReduxModule` namespace (the argument to `ReduxModule()` function)
// and the second argument should be a name of an asynchronous `redux.action()`.
// In that case, it will listen only for a "success" event of that `redux.action()`.
//
// To listen for a non-"success" event of a `redux.action()`,
// specify the full Redux event name.
// Example for a "pending" event: 'BLOG_POST: CUSTOM_EVENT_PENDING'.
//
redux.on('BLOG_POST', 'CUSTOM_EVENT', (state, action) => ({
...state,
reduxStateProperty: action.value
}))
HTTP cookies
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:
- Change
Access-Control-Allow-Origin
HTTP header from*
to an explict comma-separated list of the allowed domain names. - Add
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')
}
}
}
HTTP authentication
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')
}
}
}
}
{
http: {
authentication: {
// If a token is returned from this function, it gets sent as
// `Authorization: Bearer {token}` HTTP header.
accessToken({ useSelector, getCookie, url, originalUrl }) {
// It's recommended to check the URL to make sure that the access token
// is not leaked to a third party: only send it to your own servers.
//
// `originalUrl` is the URL argument of the `http` utility call.
// `url` is the `originalUrl` transformed by `http.transformUrl()` settings function.
// If no `http.transformUrl()` is configured then `url` is the same as the `originalUrl`.
//
if (url.indexOf('https://my.api.com/') === 0) {
return localStorage.getItem('accessToken')
}
}
}
}
}
The accessToken
is initially obtained when a user signs in: the web browser sends HTTP POST request to /sign-in
API endpoint with { email, password }
parameters and gets { userInfo, accessToken }
as a response, which is then stored in localStorage
(or in Redux state
, or in a cookie
) and all subsequent HTTP requests use that accessToken
to call the API endpoints. The accessToken
itself is usually a JSON Web Token signed on the server side and holding the list of the user's priviliges ("roles"). Hence authentication and authorization are completely covered. Refresh tokens are also supported.
This kind of an authentication and authorization scheme is self-sufficient and doesn't require "restricting" any routes: if a route's load
uses http
utility for querying an API endpoint then this API endpoint must check if the user is signed in and if the user has the necessary priviliges. If yes then the route is displayed. If not then the user is redirected to either a "Sign In Required" page or "Access Denied" page.
A real-world (advanced) example for handling "Unauthenticated"/"Unauthorized" errors happening in load
s and during http
calls:
./react-pages.js
{
...,
// Catches errors thrown from page `load()` functions.
onLoadError(error, { url, location, redirect, dispatch, useSelector, server }) {
// Not authenticated
if (error.status === 401) {
return handleUnauthenticatedError(error, url, redirect);
}
// Not authorized
if (error.status === 403) {
return redirect('/unauthorized');
}
// Not found
if (error.status === 404) {
return redirect('/not-found');
}
// Redirect to a generic error page in production
if (process.env.NODE_ENV === 'production') {
// Prevents infinite redirect to the error page
// in case of overall page rendering bugs, etc.
if (location.pathname !== '/error') {
// Redirect to a generic error page
return redirect(`/error?url=${encodeURIComponent(url)}`);
}
} else {
// Report the error
console.error('--------------------------------');
console.error(`Error while loading "${url}"`);
console.error('--------------------------------');
console.error(error.stack);
}
},
http: {
// Catches all HTTP errors that weren't thrown from `load()` functions.
onError(error, { url, location, redirect, dispatch, useSelector }) {
// JWT token expired, the user needs to relogin.
if (error.status === 401) {
handleUnauthenticatedError(error, url, redirect);
// `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
}
},
...
}
}
function handleUnauthenticatedError(error, url, redirect) {
// Prevent double redirection to `/unauthenticated`.
// (e.g. when two parallel `Promise`s load inside `load`
// and both get Status 401 HTTP Response)
if (typeof window !== 'undefined' && window.location.pathname === '/unauthenticated') {
return;
}
let unauthenticatedURL = '/unauthenticated';
let parametersDelimiter = '?';
if (url !== '/') {
unauthenticatedURL += `${parametersDelimiter}url=${encodeURIComponent(url)}`;
parametersDelimiter = '&';
}
switch (error.message) {
case 'TokenExpiredError':
return redirect(`${unauthenticatedURL}${parametersDelimiter}expired=✔`);
case 'AuthenticationError':
return redirect(`${unauthenticatedURL}`);
default:
return redirect(unauthenticatedURL);
}
}
HTTP errors
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 pageload()
functions.http.onError()
— Catches all HTTP errors that weren't thrown fromload()
functions. Should returntrue
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 request URLs
Before:
// Actions.
export const getUser = redux.action(
(id) => http => http.get(`https://my-api.cloud-provider.com/users/${id}`),
'user'
)
export const updateUser = redux.action(
(id, values) => http => http.put(`https://my-api.cloud-provider.com/users/${id}`, values)
)
After:
// Actions.
export const getUser = redux.action(
(id) => http => http.get(`api://users/${id}`),
'user'
)
export const updateUser = redux.action(
(id, values) => http => http.put(`api://users/${id}`, values)
)
// Settings.
{
...
http: {
transformUrl: url => `https://my-api.cloud-provider.com/${url.slice('api://'.length)}`
}
}
On server side, user's cookies are attached to all relative "original" URLs so http.transformUrl(originalUrl)
must not transform relative URLs into absolute URLs, otherwise user's cookies would be leaked to a third party.
File upload
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:
- In case of a
File
it will be a single file upload. - In case of a
FileList
with a singleFile
inside it would be treated as a singleFile
. - In case of a
FileList
with multipleFile
s inside a multiple file upload will be performed. - In case of an
<input type="file"/>
DOM element all its.files
will be taken as aFileList
parameter.
File upload progress can be metered by passing progress
option as part of the options
.
// React component.
function ItemPage() {
const dispatch = useDispatch()
const onFileSelected = (event) => {
const file = event.target.files[0]
// Could also pass just `event.target.files` as `file`
dispatch(uploadItemPhoto(itemId, file))
// Reset the selected file
// so that onChange would trigger again
// even with the same file.
event.target.value = null
}
return (
<div>
...
<input type="file" onChange={onFileSelected}/>
</div>
)
}
// Redux action creator
function uploadItemPhoto(itemId, file) {
return {
promise: ({ http }) => http.post(
'/item/photo',
{ itemId, file },
{ progress(percent) { console.log(percent) } }
),
events: ['UPLOAD_ITEM_PHOTO_PENDING', 'UPLOAD_ITEM_PHOTO_SUCCESS', 'UPLOAD_ITEM_PHOTO_FAILURE']
}
}
JSON Date parsing
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
}
}
Snapshotting
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.
First run the website in production mode (for example, on localhost
).
Then run the following Node.js script which is gonna snapshot the currently running website and put it in a folder which can then be hosted anywhere.
# If the website will be hosted on Amazon S3
npm install @auth0/s3 --save
import path from 'path'
import {
// Snapshots website pages.
snapshot,
// Uploads files.
upload,
// Uploads files to Amazon S3.
S3Uploader,
// Copies files/folders into files/folders.
// Same as Linux `cp [from] [to]`.
copy,
// Downloads data from a URL into an object
// of shape `{ status: Number, content: String }`.
download
} from 'react-pages/static-site-generator'
import configuration from '../configuration'
// Temporary generated files path.
const generatedSitePath = path.resolve(__dirname, '../static-site')
async function run() {
// Snapshot the website.
await snapshot({
// The host and port on which the website
// is currently running in production mode.
// E.g. `localhost` and `3000`.
host: configuration.host,
port: configuration.port,
pages: await generatePageList(),
outputPath: generatedSitePath,
//
// Set this flag to `true` to re-run all `load`s on page load.
// For example, if the data used on the page can be updated
// in-between the static site deployments.
// reloadData: true
})
// Copy assets (built by Webpack).
await copy(path.resolve(__dirname, '../build/assets'), path.resolve(generatedSitePath, 'assets'))
await copy(path.resolve(__dirname, '../robots.txt'), path.resolve(generatedSitePath, 'robots.txt'))
// Upload the website to an Amazon S3 bucket.
await upload(generatedSitePath, S3Uploader({
// Setting an `ACL` for the files being uploaded is optional.
// Alternatively a bucket-wide policy could be set up instead:
//
// {
// "Version": "2012-10-17",
// "Statement": [{
// "Sid": "AddPerm",
// "Effect": "Allow",
// "Principal": "*",
// "Action": "s3:GetObject",
// "Resource": "arn:aws:s3:::[bucket-name]/*"
// }]
// }
//
// If not setting a bucket-wide policy then the ACL for the
// bucket itself should also have "List objects" set to "Yes",
// otherwise the website would return "403 Forbidden" error.
//
ACL: 'public-read',
bucket: confiugration.s3.bucket,
accessKeyId: configuration.s3.accessKeyId,
secretAccessKey: configuration.s3.secretAccessKey,
region: configuration.s3.region
}))
console.log('Done');
}
run().catch((error) => {
console.error(error)
process.exit(1)
})
// Get the list of all page URLs.
async function generatePageList() {
const pages = [
'/',
'/about',
// Error pages need a `status` property
// to indicate that it shouldn't throw on such errors
// and should proceed with snapshotting the next pages.
{ url: '/unauthenticated', status: 401 },
{ url: '/unauthorized', status: 403 },
{ url: '/not-found', status: 404 },
{ url: '/error', status: 500 }
]
// (optional) Add some dynamic page URLs, like `/items/123`.
// Query the database for the list of items.
const { status, content } = JSON.parse(await download(`https://example.com/api/items`))
if (status !== 200) {
throw new Error('Couldn\'t load items')
}
// Add item page URLs.
const items = JSON.parse(content)
return pages.concat(items.map(item => `/items/${item.id}`))
}
The snapshot()
function snapshots the list of pages
to .html
files and then the upload()
function uploads them to the cloud (in this case to Amazon S3). The snapshot()
function also snapshots a special base.html
page which is an empty page that should be used as the "fallback", i.e. the cloud should respond with base.html
file contents when the file for the requested URL is not found: in this case base.html
will see the current URL and perform all the routing neccessary on the client side to show the correct page. If the snapshot()
function isn't passed the list of pages
to snapshot (e.g. if pages
argument is null
or undefined
) then it will only snapshot base.html
. The static website will work with just base.html
, the only point of snapshotting other pages is for Google indexing.
If the website is hosted on Amazon S3 then the IAM policy should allow:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:ListBucket"
],
"R