A bunch of ideas for developers working on large production apps.
Average App Anatomy
To reach the widest possible audience I will use a fairly common setup for the demonstration. Our average app ...
- has a static landing page with some marketing pitch.
- has some public pages, at least a login and a register.
- has a handful of private pages.
- uses JWT token for authentication.
- is written in React with redux, react-router and axios.
- is bootstrapped with create-react-app.
I work at a consulting company and this is what comes around most often. Hopefully you can apply the below ideas to your preferred stack too.
Tip #1: Have a solid API layer
The api should handle everything networking related.
Avoid duplicating URLs and headers, use a base API instance instead.
Handle authentication here. Make sure to add the auth token to both
localStorage
and the base API instance.Use API interceptors for generic fallback behaviors - like global loading indicators and error notifications.
import axios from 'axios'
import store from '../store'
import { startLoading, stopLoading, notify } from '../actions'
const JWT_TOKEN = 'JWT_TOKEN'
// have a base api instance to avoid repeating common config - like the base URL
// github.com/axios/axios#custom-instance-def…
const api = axios.create({
baseURL: process.env.REACT_APP_API_URL,
timeout: process.env.REACT_APP_API_TIMEOUT
})
// add the Auth header to the base API instance once here to avoid repeated code
if (localStorage.hasItem(JWT_TOKEN)) {
const token = localStorage.getItem(JWT_TOKEN)
api.defaults.headers.Authorization = `Bearer ${token}`
}
// keep networking logic - like handling headers and tokens - in the network layer
export function login (token) {
api.defaults.headers.Authorization = `Bearer ${token}`
localStorage.setItem(JWT_TOKEN, token)
}
export function logout () {
delete api.defaults.headers.Authorization
localStorage.removeItem(JWT_TOKEN)
}
// handle generic events - like loading and 500 type errors - in API interceptors
api.interceptors.request.use(config => {
// display a single subtle loader on the top of the page when there is networking in progress
// avoid multiple loaders, use placeholders or consistent updates instead
store.dispatch(startLoading())
return config
})
api.interceptors.response.use(
resp => {
store.dispatch(stopLoading())
return resp
},
err => {
store.dispatch(stopLoading())
// if you have no specific plan B for errors, let them be handled here with a notification
const { data, status } = err.response
if (500 < status) {
const message = data.message || 'Ooops, something bad happened.'
store.dispatch(notify({ message, color: 'danger' }))
}
throw err
}
)
export default api
Tip #2: Keep the State Simple
Since loading and generic error handling is already covered by the API, you won't need to use full-blown async actions. In most cases, it is enough to cover the success event.
action.js
import articlesApi from '../api/articles'
const LIST_ARTICLES = 'LIST_ARTICLES'
export function listArticles () {
return async dispatch => {
// no need to handle LIST_ARTICLES_INIT and LIST_ARTICLES_ERROR here
const articles = await articlesApi.list()
dispatch({ type: LIST_ARTICLES, articles })
}
}
reducer.js
import { LIST_ARTICLES } from '../actions/articles'
export function articles (state = [], { type, articles }) {
switch (type) {
case LIST_ARTICLES:
return articles
default:
return state
}
}
You should only handle init and error events when you have a specific plan B.
Tip #3: Keep Routing Simple
Implementing a correct ProtectedRoute
component is tricky. Keep two separate router trees for public and protected pages instead. Login and logout events will automatically switch between the trees and redirect to the correct page when necessary.
import React from 'react'
import { Switch, Route, Redirect } from 'react-router-dom'
// isLoggedIn is coming from the redux store
export default App ({ isLoggedIn }) {
// render the private routes when the user is logged in
if (isLoggedIn) {
return (
<Switch>
<Route exact path="/home" component={HomePage} />
<Route exact path="/article/:id" component={ArticlePage} />
<Route exact path="/error" component={ErrorPage} />
<Redirect exact from="/" to="/home" />
<Route component={NotFoundPage} />
</Switch>
)
}
// render the public router when the user is not logged in
return (
<Switch>
<Route exact path="/login" component={LoginPage} />
<Route exact path="/register" component={RegisterPage} />
<Redirect to="/login" />
</Switch>
)
}
The above pattern has a well-behaved UX. It is not adding history entries on login and logout, which is what the user expects.
Tip #4: Init the App Properly
Do not render anything until you know if the user is logged in or out. Making a bold guess could result in a short flicker of public/private pages before redirecting to the correct page.
import React from 'react'
import ReactDOM from 'react-dom'
import App from './App'
import store from './store'
// do not render the app until we know if there is a logged in user or not
store.dispatch(getMe()).then(renderApp)
function renderApp () {
ReactDOM.render(<App />, document.getElementById('root'))
}
getMe()
should call the /me
endpoint which returns the logged in user or a 401 (Unauthorized) error code. Checking for a JWT token in the localStorage is not enough, the token might be expired which could result in an infinite redirect loop for the user.
export function getMe (data) {
return async dispatch => {
try {
const user = await userApi.getMe(data)
dispatch({ type: LOGIN, user })
} catch (err) {
userApi.logout()
}
}
}
Tip 5: Use the Landing Page
Returning users will already have some interest in your product and a cached app in their browser. Newcomers won't, and they will judge quickly.
Server Side Rendering your whole app can give a nice first impression, but it is one of the toughest techs out there. Don't jump on that train just yet. Most of the time you can rely on a simple heuristic instead: newcomers will most likely start on your landing page.
Just keep your landing page simple, static and separate from your app. Then use preload or HTTP/2 push to load your main app while the user is reading the landing page. The choice between the two is use-case specific: go for prefetch if you have a single big bundle and go for HTTP/2 push in case of multiple small dynamically named chunks.
I hope I could teach a few new tricks! If you got so far, please help by sharing the article. I may create a second one about creating reusable components if this one gets enough love.
Thanks!