Whenever I take a spell from ReactJS it sometimes takes a little bit of time to warm up again. You might be the same.
Hopefully this article will serve as a good reminder for when you need to stretch your legs again.
Facebook are not known for the fostering of healthy relationships and interactions, but they make a darned good Javascript framework.
The name React (in my option) comes from the fact that the need to render UI is a reaction to the changes in the data that the components themselves are bound to. But without some extra libraries, the React framework is of limited real-world use.
My favourite friends of React are: React-Redux, React-Router, Redux-Thunk and Hooks.
For this refresher, I'm going to present a simple, but reasonably complete application that:
Node is required for setting up a React application, but I recommend the use of nvm
instead to be able to manage multiple versions of NodeJS/npm. You can download nvm from: https://github.com/coreybutler/nvm-windows/releases
I also recommend yarn
over npm. Without getting in the wars too much, the parallel download of packages is a noticeable feature. But if you are used to npm and prefer that, you can get along with this refresher fine. You can get yarn with:
npm install --global yarn
Create an empty folder called PlanetUI
. Our Single Page Application is going to be used to view the available planets in the list, allow us to get important information about each planet, and set them as favourites if you like. Additionally, you can switch to the Favourites view to show all the planets that have been set as a favourite.
A trivial application of course, but it demonstrates many real world elements required for a React UI application.
Open the empty PlanetUI folder using VSCode. And open the Terminal pane within VSCode for running commands.
In order to create the actual basic React application structure, I like to use create-react-app
. This is due to its simplicity, and the fact that all the webpack business is handled behind the scenes for you.
Rather than re-invent the wheel, follow this really clear guide on how to use create-react-app
: https://www.code-boost.com/create-react-app
Once you are done, head back to here and we can talk through the files of the PlanetUI application.
Even small applications with a number of components can benefit from a little bit of design. This helps to get your head clearly in the game and plan out what you will need in terms of components. Also to start thinking about the kind of state you might need. This is my little mockup using Inkscape:
Browse or clone the repo at: https://github.com/sausagejohnson/PlanetUI
Copy in the files from the repo into your newly created React app.
To get all the extra required libraries: Redux, React-Redux, Redux-Thunk and React-Router, you'll need to add them with:
yarn add redux yarn add react-redux yarn add redux-thunk yarn add react-router
You can build and serve the UI anytime using yarn start
.
As this is not a tutorial as such, but rather a tour, we won't work through building up an app. Instead, I will just show each file and point out items of note.
Just some customised syles for the PlanetUI app to add some aesthetics. I won't list it out as there's nothing specific to say about the styling itself.
import React from 'react'; import ReactDOM from 'react-dom'; import './index.css'; import App from './App'; import { createStore, applyMiddleware } from 'redux'; import thunk from 'redux-thunk'; import { Provider } from 'react-redux'; import reducer from './reducers/reducer'; const store = createStore(reducer, applyMiddleware(thunk)); ReactDOM.render( <React.StrictMode> <Provider store={store}> <App /> </Provider> </React.StrictMode>, document.getElementById('root') );
This is the root of our application. Stores and middleware loading are provided by Redux. A middleware will be used to add the ability to run a function from within an action. Normally an action can only work with a standard object. Using middleware, will allow us asynchronously load in data remotely using fetch
and then pass the data into the store.
The middleware itself will be Redux-Thunk.
React-Redux is the bridge between the Redux and React and the Provider component is used to wrap the entire application. This gives any component or view the ability to access any part of the state cleanly and easily.
Our reducer (which we will cover later) is used to affect the data in the store containing our state. The store is created by passing in the reducer and the thunk. The store is passed into the Provider, for all components to access at will.
Finally, the app is rendered into the root element on our page.
import local_planets_data from '../reducers/planets.json'; export const populate_store_with_data = (data) => { return { type: 'LOAD_PLANETS_INTO_STORE', data: data } } export const set_loading = () => { return { type: 'SET_LOADING' } } export const set_loaded = () => { return { type: 'SET_LOADED' } } export const set_selected_planet = (id) => { return { type: 'SET_SELECTED_PLANET', id: id } } export const set_a_favourite = (data) => { return { type: 'SET_A_FAVOURITE', data: data } } export const fetchRemoteDataThunk = (dispatch) => { return (dispatch) => { dispatch(set_loading()); return fetch('https://waynejohnson.net/planets', { mode: 'cors', method: 'GET', headers: { 'Content-Type': 'text/plain', 'Accept': 'application/json' } }) .then(response => response.json()) .then(json => { dispatch( populate_store_with_data(json) ) }).then( () => { dispatch(set_loaded()); }); } } export const fetchLocalPlanetDataThunk = (dispatch) => { return (dispatch) => { dispatch(set_loading()); setTimeout(() => { dispatch( populate_store_with_data(local_planets_data) ); dispatch(set_loaded()); }, 2000); } }
There are several Action Creators in the file.
For sake of ease I've thrown the thunks into the actions file, but ideally these would imported from their own file.
The Action Creators are written as simple functions that can pass in data. This makes it easy for a component to import an action which can be dispatched and data passed along too.
A note on the two fetch data actions above: I've written them with Promise chaining with then
rather than using the await
operator on the promises. I'm still on the fence here, but you can re-write them accordingly to taste.
const initialState = { loading: false, planets: [], selectedPlanet: null, favourites: [] } const reducer = (state = initialState, action) => { switch (action.type) { case 'LOAD_PLANETS_INTO_STORE': return { ...state, planets: action.data } case 'SET_LOADING': return { ...state, loading: true } case 'SET_LOADED': return { ...state, loading: false } case 'SET_SELECTED_PLANET': return { ...state, selectedPlanet: action.id } case 'SET_A_FAVOURITE': if (action.data.checked){ //add a favourite return { ...state, favourites: [...state.favourites, action.data.id] } } else { //remove a favourite return { ...state, favourites: [ ...state.favourites.filter(ids => ids !== action.data.id) ] } } default: return state } } export default reducer;
Not much to say on the reducer file for the reducer itself, as it's pretty standard fare: responding to the dispatched action, and changing the data in the store.
But on the initialState that has been set up there, note the state has been broken up into a number of pieces:
As we'll see shortly, any component can use the useSelector
hook to access and affect any part of the state.
import {BrowserRouter as Router, Route, NavLink, Redirect} from 'react-router-dom'; import { useEffect } from 'react'; import { useDispatch } from 'react-redux'; import { fetchRemoteDataThunk, fetchLocalPlanetDataThunk } from './actions/actions'; import ListContainer from './views/list-container.js'; import Favourites from './views/favourites.js'; import LoadIndicator from './components/loadindicator.js'; import './App.css'; const App = () => { const dispatch = useDispatch(); useEffect( () => { dispatch(fetchRemoteDataThunk()) //dispatch(fetchLocalPlanetDataThunk()) }, [dispatch] ); return ( <div className="App"> <h1>Planet Viewer</h1> <Router> <div className="tabs"> <LoadIndicator></LoadIndicator> <NavLink to="/list" activeClassName="active">List</NavLink> <NavLink to="/favourites" activeClassName="active">Favourites</NavLink> </div> <Route path="/list" component={ListContainer}></Route> <Route path="/favourites" component={Favourites}></Route> <Redirect to="/list" /> </Router> </div> ); } export default App;
This is the main app component itself. It uses two view components: ListContainer
and Favourites
and these act as two tabbed views to make up a small Single Page Application (SPA).
Also used is the LoadIndicator component which shows if loading is in progress.
fetchRemoteDataThunk
and fetchLocalPlanetDataThunk
are imported actions for loading the initial data. You can choose which one you'd rather use.
The useEffect hook is going to be used as the trigger to load data into the application when it first starts. Providing an empty array to useEffect is the equivalent to the old-school lifecycle method componentDidMount
. Therefore, the loading will only occur on first load and will then push the data into the store.
Notice that I have passed in dispatch
into the array for useEffect. This is to remove an ugly warning. Because the array with dispatch
is unchanging, useEffect still behaves like componentDidMount
.
The Router
is added here, which contains NavLink
s. These are used instead of a Nav
, because NavLinks allow the styling of the actively selected tab or navigation element.
The Route paths are added here too. Each binds to a specific component to load on navigation which is either list
or favourites
. The default route is list
.
Now to the two view components.
import List from '../components/list.js'; import Details from '../components/details.js'; const ListContainer = () => { return ( <div className="list-container"> <List></List> <Details></Details> </div> ); } export default ListContainer;
This is only a dumb component that groups the List and Details components. I'll dig into those two components first. We'll look at favourites after that.
import { useSelector, useDispatch } from 'react-redux'; import { set_selected_planet } from '../actions/actions'; import PlanetItem from './planet-item.js'; const List = () => { const dispatch = useDispatch(); const planets = useSelector(state => state.planets); const selectedPlanet = useSelector(state => state.selectedPlanet); return ( <div className="list"> { planets.map( planet => ( <PlanetItem key={planet.id} planet={planet} onClick={ () => dispatch(set_selected_planet(planet.id)) } active={selectedPlanet === planet.id } > </PlanetItem> )) } </div> ); } export default List;
This component uses two useSelector hooks. One to connect to the planets
array state in the store, and one to connect to the selectedPlanet
state. For each planet, a PlanetItem
component is rendered.
When a PlanetItem is clicked, the useDispatch hook is used to update the selectedPlanet
state in the store.
import { useSelector, useDispatch } from 'react-redux'; import { set_a_favourite } from '../actions/actions'; const Details = (props) => { const dispatch = useDispatch(); const planets = useSelector(state => state.planets); const selectedPlanet = useSelector(state => state.selectedPlanet); const favourites = useSelector(state => state.favourites); const planet = planets.find(p => p.id === selectedPlanet); const isFavourite = (favourites.filter(i=>i === selectedPlanet).length > 0); return ( <div className="details"> { planet ? <> <div> <h2>{ planet.name }</h2> <p>Environment: <span className="environment">{ planet.environment}</span></p> <p>Resources:</p> <ul> { planet.resources.map( (resource, index) => ( <li key={index} className="resource">{ resource }</li> )) } </ul> </div> <div className="select-favourite"> <div className="control"> <input id="fav-check" type="checkbox" checked={isFavourite} onChange={(control) => { dispatch(set_a_favourite( {id:selectedPlanet, checked: control.currentTarget.checked}))} }/> <label htmlFor="fav-check">Favourite</label> </div> </div> </> : <span></span> } </div> ); } export default Details;
This is the second component in the pair under the list-component view. It's job is to respond to the change in the selectedPlanet state. But to the user, it appears to respond to the selection click from the list.
Details uses three out of the four pieces of state by using three useSelector hooks. It uses selectedPlanet
for the selected index, and the list of planets
to drill into the data to display.
There is also a Favourite
checkbox here which is both set based on the favourites
indexes, and also dispatches the set_a_favourite
action to update the favourites
array of indexes. This is a special action that will update the state by either adding or removing an index based on the checked
value.
import { set_selected_planet } from '../actions/actions'; import { useSelector, useDispatch } from 'react-redux'; const Favourites = () => { const dispatch = useDispatch(); const favourites = useSelector(state => state.favourites); const planets = useSelector(state => state.planets); const favouritePlanets = planets.filter(p => favourites.includes(p.id)) return ( <div className="favourites"> { favouritePlanets.length > 0 ? favouritePlanets.map( planet => ( <div key={planet.id} className="planet-item" onClick={ () => dispatch(set_selected_planet(planet.id)) }>{` \u2605 ${planet.name} `} </div> )) : <span>No favourites yet.</span> } </div> ); } export default Favourites;
Finally we come to the favourites
view component. It uses two of the pieces of state: the favourites
indexes, and the array of planets
.
These are displayed as a list of favourite planets. When clicking on any in this list, the set_selected_planet
action is dispatched to change the state in the store, and the selected planet will be different when navigating back to the list.
I hope this is helpful. There are many real world concepts covered here, but not all. There has been no analysis of unnecessary renders. Don't forget to look into memo, and test your components to ensure that only the state required is changed so that only the required components react to those changes.
You can see that my favourite friends of ReactJS have matured and are so much easier to reason with. Hooks have removed the need to use connect
and mapping in the components which is a great relief.
There has been no need to implement any local state in any of the components. All have been driven by the useSelector hook back from the main application state.
Finally a big thanks to my friends Andrew and Adam for reviewing the content of this article for me.