- React Kata
Learn react by example. In this walkthrough we will be building a SW Manager portal, where we can view people and starships from the Star Wars universe. We will be utilizing the Star Wars API (SWAPI) which is publicly available without authentication. Later we will enhance this app by building your squad and adding starships in hangar. Don't expect too much, this will be a simple app.
Each branch of this repository will represent a part of this tutorial. In order to avoid spoilers to exercises, avoid running the master branch, but that is up to you.
module-1
: Installation, setup, adding UI component library (Ant Design), adding routermodule-2
: Adding style, Fetching API, useState hook, rendering statemodule-3
: Promise.all, Component and Propsmodule-4
: Redux state management, useDispatch and useSelector hooksmodule-5
: tackling exercises in Module 4, useEffectmodule-6
: generator function basics and Redux Sagasmodule-7
: withLoading Higher Order Component (HOC); unit testing redux, sagas and componentsmodule-8
: Wrap up; the app is now completed.
Finished product is accessible here: https://dmcruz.github.io/react-kata/
If you don't want to follow the walkthrough and just want to run the app, follow the steps.
- npm install
- npm start
- Open in browser: http://localhost:3000/react-kata/
- Install nodejs
- Install VSCode
- Boiler plate creation
Create a default typescript based react app using npx
npx create-react-app my-app --template typescript
- Open the project using VSCode
cd my-app
code .
-
Since the project has been bootstrapped from create-react-app the dependencies have been installed. All you have to do is run
npm start
in terminal to run the app.Note: run
npm install
beforenpm start
if you didn't start from scratch. -
Once the app has been compiled successfully, access the URL in your browser: http://localhost:3000
-
You can open another instance of a terminal and run tests.
To run tests:
npm run test
To run a specific test file:
npm run test src/App.test.tsx
To run test with coverage:
npm run test -- --coverage src/App.test.tsx
- Install recommended VSCode extensions
- Prettier
- Follow this guide to configure VSCode to prettify (autoformat) the codes on save: https://glebbahmutov.com/blog/configure-prettier-in-vscode/
- Alternatively, here's the simplified instructions
- Install prettier VSCode extension
- Install prettier:
npm install --save-dev --save-exact prettier
- In this repository you can refer to prettierrc.json and .vscode/settings.json and add them in your project
- Saving should automatically indent, or apply the rules set in prettierrc.json.
The built-in template does not come with a UI library and routing solution.
To add Ant Design:
npm i --save antd
To add router:
npm i --save react-router-dom
Refer to the docs:
- Import the ant design stylesheet manually by adding this import statement on App.tsx
import "antd/dist/antd.css";
In this section we will create the layout of our website. It will be a simple layout: Header which will contain the menu, Content to render the page contents, and Footer.
- Under
src
folder createcomponents
folder. - Under
src/components
folder, createlayout
folder. - Under
layout
folder, createMyLayout.tsx
.
import { Layout, Menu } from 'antd';
const { Header, Content, Footer } = Layout;
const MyLayout: React.FC = ({ children }) => {
return (
<Layout className="layout">
<Header>
<Menu theme="dark" mode="horizontal">
<Menu.Item key="home" title="Home">
Home
</Menu.Item>
<Menu.Item key="nav1" title="Nav 1">
Nav 1
</Menu.Item>
<Menu.Item key="nav2" title="Nav 2">
Nav 2
</Menu.Item>
</Menu>
</Header>
<Content style={{ padding: '0 50px', minHeight: '50px' }}>
{children}
</Content>
<Footer>React Kata © 2021</Footer>
</Layout>
);
};
export default MyLayout;
- Modify
App.tsx
, remove all the code. Import MyLayout and render it.
import MyLayout from './components/layout/MyLayout';
import 'antd/dist/antd.css';
function App() {
return <MyLayout />;
}
export default App;
Create pages with the aim to render content specific to each.
- Under
components
, createHome.tsx
const Home = () => {
return (
<div>
<h1>Home</h1>
</div>
);
};
export default Home;
- Under
components
, createPeople.tsx
const People = () => {
return (
<div>
<h1>People</h1>
</div>
);
};
export default People;
Add routing in menu and render content according to selected link.
- Modify
MyLayout.tsx
, importLink
fromreact-router-dom
in order to define hyperlinks.
import { Link } from "react-router-dom";
- Enclose the Menu Items with
<Link>
and pointing to path
Partial snippet shows the modified part of MyLayout.tsx:
<Menu.Item key="home" title="Home">
<Link to="/">Home</Link>
</Menu.Item>
<Menu.Item key="people" title="People">
<Link to="/people">People</Link>
</Menu.Item>
Full snippet:
import { Link } from 'react-router-dom';
import { Layout, Menu } from 'antd';
const { Header, Content, Footer } = Layout;
const MyLayout: React.FC = ({ children }) => {
return (
<Layout className="layout">
<Header>
<Menu theme="dark" mode="horizontal">
<Menu.Item key="home" title="Home">
<Link to="/">Home</Link>
</Menu.Item>
<Menu.Item key="people" title="People">
<Link to="/people">People</Link>
</Menu.Item>
<Menu.Item key="nav2" title="Nav 2">
Nav 2
</Menu.Item>
</Menu>
</Header>
<Content style={{ padding: '0 50px', minHeight: '50px' }}>
{children}
</Content>
<Footer>React Kata © 2021</Footer>
</Layout>
);
};
export default MyLayout;
- Modify
App.tsx
and add importBrowserRouter
,Routes
(previouslySwitch
in react-router-dom v5),Route
.
import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
- Import
Home
andPeople
components inApp.tsx
.
import Home from './components/Home';
import People from './components/People';
- Modify App.tsx rendered component to:
<Router>
<MyLayout>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/people" element={<People />} />
</Routes>
</MyLayout>
</Router>
What it does is render the component according the route. If URL is /
it will render Home in the content area. If URL is /people
, People will be rendered in the content area.
- Open http://localhost:3000/, click the links to observe the changes.
Refer to branch module-2 for the source code.
- In
src/components/layout
, createMyLayout.css
.site-content {
background-color: #fff;
min-height: 280px;
padding: 25px;
}
- Modify
MyLayout.tsx
and import the css that was just created.
import './MyLayout.css';
- Still in
MyLayout.tsx
, enclose the {children} elements with div referring to site-content class.
<div className="site-content">{children}</div>
The result should be the content area will have a white background color.
In this section we will be using fetch
which is a built in function in modern browsers. It has the ability to fetch resources across the network.
Read more here: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API https://reactjs.org/docs/faq-ajax.html
We will be using Star Wars API (SWAPI) for RestAPI resources.
Read more here: https://swapi.dev/documentation
-
Create
PeopleList.tsx
under/src/components/people
. The goal is to create a button that when clicked will request RestAPI.1.1 Import Button from antd.
import { Button } from "antd";
1.2 Render the Button.
return <Button type="primary">Fetch Data</Button>;
1.3 Add an onClick handler for the button. This will trigger a fetch request and print the results in the console. Inspect the results to understand the response of the API.
const handleClickFetch = (e: any) => { fetch('https://swapi.dev/api/people') .then((res: Response) => res.json()) .then( (data: any) => { console.log(data); }, (error) => { console.error(error); } ); }; return ( <Button type="primary" onClick={handleClickFetch}> Fetch Data </Button> );
Full snippet of
PeopleList.tsx
:import { Button } from 'antd'; const PeopleList = () => { const handleClickFetch = (e: any) => { fetch('https://swapi.dev/api/people') .then((res: Response) => res.json()) .then( (data: any) => { console.log(data.results); }, (error) => { console.error(error); } ); }; return ( <div> <div> <Button type="primary" onClick={handleClickFetch}> Fetch Data </Button> </div> </div> ); }; export default PeopleList;
-
Modify
People.tsx
and importPeopleList
and render it.import PeopleList from './people/PeopleList'; const People = () => { return ( <div> <h1>People</h1> <PeopleList /> </div> ); }; export default People;
-
Check the browser and click the button. On the browser's console you should see the response from the API.
Now let's render the result on the screen. For that we will be utilising local state to store the data. Functional components are stateless components. In order to use state, we have to add useState
hook from react.
Read more: https://reactjs.org/docs/hooks-state.html
-
Import useState from react
import { useState } from "react";
-
useState()
is a function that accepts initial state and returns 2 values: the current state and the function that updates it. What we need is to retrieve a list of people from SWAPI People api and return to the screen. For that we will construct a list state object and initialize it to an empty array.const [list, setList] = useState([]);
-
Call
setList
and pass the results obtained from the API. Inspecting SWAPI People result, list is returned underresults
property of the response.fetch('https://swapi.dev/api/people') .then((res: Response) => res.json()) .then( (data: any) => { setList(data.results); // updated here }, (error) => { console.error(error); } );
-
Now we render the results on the component using the
list
state object. In react, when rendering multiple items, they have to be uniquely identified with akey
. We will usemap
function to return a rendered item. For now we only display the name of the Star Wars person. We use the built in index parameter of map to pass to key.Read more: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map
const viewPerson = list.map((item: any, index: number) => ( <li key={index}>{item.name}</li> ));
-
Update the rendered item in the screen, render
viewPerson
. Save the changes.return ( <div> <div> <Button type="primary" onClick={handleClickFetch}> Fetch Data </Button> </div> <div> <ul>{viewPerson}</ul> </div> </div> );
-
Check the browser, click on People menu. Click Fetch and you should see a list of Star Wars person in the screen.
In this module, we will be learning about Promise.all, and passing props to child components.
In Module 2, we learned about fetching API. If you notice, the API returns only the first page. What if we want to retrieve all data at once?
For that refer to module-3
branch of this repository and browse to /src/services/FetchHelper.ts
which has getAllPeople()
function.
To consume the data in this function, here's a sample code:
(async () => {
try {
const people = await FetchHelper.getAllPeople();
} catch (error: any) {
console.error(`${error}`);
}
})();
Read more:
- Promise: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise
- Promise.all: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/all
It is also possible to call fetch multiple times, this is just to show how to use Promise.all.
Time for some exercise. If in doubt you can always check module-3
branch in this repository.
The goal here is to learn about parent and child component communication through props. https://reactjs.org/docs/components-and-props.html
- In the previous module, we displayed a list of names which is rendered by
viewPerson
embedded inPeopleList.tsx
. - Extract the
viewPerson
component into a new componentPersonRow.tsx
and render the person attributes in tabular form. UseRow
andCol
components of Ant Design. https://ant.design/components/grid/ - Pass the person attributes in
PersonRow
as properties.
In the Module 2, we learned about local state. But what if we need to share this data in another component. For that, we will be using Redux which can manage global state.
In this module, the goal is to setup Redux, and apply it.
References:
Add redux
, react-redux
, and redux-logger
dependencies by running the following command:
npm i --save redux react-redux redux-logger
Add @types/redux-logger
as a dev dependency:
npm i --save-dev @types/redux-logger
The goal is to create a global state for people list.
-
First, create
/src/redux
folder. Anything that has to do with redux will be placed here. -
We will group reducers per feature folder like
/src/redux/{feature}
.Under feature folder we will be creating redux objects:
-
action
- an object that represents an intention to change the state. Action must specify a type field and although optional another parameter is payload or the "new value" to be sent. All action objects for the feature are placed here. -
reducer
- a function that accepts a previous state and an action as inputs and returns the new state. Reducers are the one that knows how to modify the state. All reducer objects for the feature are placed here.
-
-
Let's start creating our People reducer. Create
/src/redux/people
folder. -
Create people reducer file
/src/redux/people/people.reducer.ts
INITIAL_STATE defines the model of your state. Any data that has to be observed or changed will go here.
const INITIAL_STATE = { list: [], }; const peopleReducer = (state = INITIAL_STATE, action: any) => { switch (action.type) { case 'SET_PEOPLE_LIST': return { ...state, list: action.payload, }; default: return state; } }; export default peopleReducer;
-
Create an action file in the same path
people.action.ts
export const setPeople = (list: any) => ({ type: 'SET_PEOPLE_LIST', payload: list, });
-
Create store file
/src/redux/store.ts
6.1 Import createStore, applyMiddleware, combineReducers from redux
6.2 Import logger middleware from redux-logger. Logger will be used in development mode so that changes to the global state is logged in browser's console.
6.3 Import the people reducer that we created and build the reducer object.
6.4 Create the store object and export it.
import { createStore, applyMiddleware, combineReducers } from 'redux'; import logger from 'redux-logger'; import peopleReducer from './people/people.reducer'; const middlewares = []; if (process.env.NODE_ENV === 'development') { middlewares.push(logger); } const rootReducer = combineReducers({ people: peopleReducer, }); const store = createStore(rootReducer, applyMiddleware(...middlewares)); export default store;
We will be connecting the store object into all our components. We do this using Provider
component from react-redux
. Provider
will wrap around the entire application in order for all the components under it to have access to the store object.
-
Modify
src/index.js
. Import Provider from react-redux.import { Provider } from 'react-redux';
-
Import store
import store from './redux/store';
-
Enclose
App
withProvider
component, passingstore
as a prop.<Provider store={store}> <App /> </Provider>
-
Refer to
/src/index.tsx
inmodule-4
branch for the changes.
Let's go back to /src/components/people/PeopleList.tsx
and remove the local states and use global state instead.
-
Remove this import:
import { useState } from "react";
-
Remove this line:
const [list, setList] = useState([]);
-
Remove the usage of
setList
inhandleClickFetch
. -
We will be needing 2 hooks from react redux in order to dispatch a change action and extract a value from the global state. We accomplish this by using
useDispatch()
anduseSelector
hooks. Read more here: https://react-redux.js.org/api/hooksimport { useDispatch, useSelector } from "react-redux";
-
Create a dispatch object and create a list object to extract the people list from the global state.
const dispatch = useDispatch(); const list: [] = useSelector((state: any) => state.people.list);
-
Import setPeople action from
/src/redux/people/people.action
import { setPeople } from '../../redux/people/people.action';
-
In the Fetch Data click button handler, dispatch the setPeople action and pass the result of the API here.
const peopleList = await FetchHelper.getAllPeople(); dispatch(setPeople(peopleList));
-
Save the changes and check if the fetch data still works and list is rendered.
-
Inspect the browser console, and see that redux logger is logging whenever there's a change in the state.
What we have done in the previous section is replace local state with global state. Let's prove that the state can be reused outside the page the state was initially created.
We will be modifying /src/components/Home.tsx
and show a random person when it's available.
-
SWAPI People API returns a maximum of 82 records. Create a random number with that maximum index.
-
Extract the people list and pass the random index.
const randomNumber = Math.floor(Math.random() * 82); const randomPerson = useSelector( (state: any) => state.people.list[randomNumber] );
-
Render the randomPerson value. Since
Home
is rendered first before fetching thePeople
list, use the optional chaining operator so that in case there is no value in the state, it will not cause a runtime exception in rendering.return ( <div> <h1>Home</h1> {randomPerson?.name} </div> );
-
Save the changes and reload Home. At first it should be empty. Transfer to People and fetch data, once data is fetched, go back to Home and a random person name should appear.
Create a random person widget that will display all attributes available in a presentable way. Render this in Home
. I will create this component in the next module.
SWAPI Starships API: https://swapi.dev/api/starships/
Create a new route to /starships
that will render Starship list on load. Use useEffect
hook for this. Read more here: https://reactjs.org/docs/hooks-effect.html
This module is more about tackling the exercises. There are a lot of changes so you can take a look at module-5
branch.
Added dependency:
-
ts-md5 - for hashing md5 used for generating Gravatar identicon
npm i --save ts-md5
- Module 4 Exersise 1: RandomPerson widget to generate a featured person in Home page
- Module 4 Exercise 2: Starship component and redux created
- Generic getAll function is created in FetchHelper.ts
- People List is now a grid of cards
useEffect
hook is equivalent to componentDidMount, componentDidUpdate, and componentWillAmount lifecycle methods. When the component is loaded this effect is called.
There are 2 arguments in this function. First argument is the behavior or function, second argument is the array of dependencies. React will compare previous value of dependency with the new value to trigger if it needs to perform the effect function. If second argument is an empty array, it will invoke useEffect once only.
The following snippet retrieves Starships API on load. This is only done once because the second argument is an empty array.
useEffect(() => {
(async () => {
try {
const list = await FetchHelper.getAll(SwapiUrls.STARSHIPS);
dispatch(setStarships(list));
} catch (error: any) {
message.error(`${error}`);
}
})();
// eslint-disable-next-line
}, []);
Goal in this module is to apply redux sagas. Learn more about it here:
The foundation of redux saga is generator functions. Learn more about generator function: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/function*
Example of a generator function:
function* fetchStarshipsAsync() {
const list = yield FetchHelper.getAll(SwapiUrls.STARSHIPS);
yield put(setStarships(list));
}
In generator function, each yielded function is awaited before proceeding to the next one. In the example, result of Starships API is awaited first and set to list before the dispatching of setStarships is done. Calling this function does not execute immediately, it has to be iterated by using next(). Saga middleware takes care of handling this.
// this will not do anything and will be paused (suspended)
fetchStarshipsAsync();
// this will execute the yielded functions
const myFunc = fetchStarshipsAsync();
myFunc.next();
myFunc.next();
As you have noticed API calls exists in the component such as PeopleList and StarshipList. We will be taking out these API calls out of components and delegate them to sagas. This will result in cleaner code and clear separation of concern: components are for rendering while sagas are for managing side effects.
Currently, PeopleList fetches People API on load. Our target will be for PeopleList to dispatch a request for People API on load. A saga watcher function will trigger the API when it receives a fetch request. We will be also adding a loading indicator when fetching data is in progress.
Let's imagine 3 events:
requestStart
- this is where loading will be set to true as it marks that the fetch is in progress. This will trigger the request to fetch API.requestSuccess
- this event indicates that the API has returned the response data, and loading should be set to false.requestError
- this event indicates an error during API request, so we can obtain error message here, and loading should be set to false.
You will find this is a common pattern we will be applying in sagas.
-
Add dependency
npm install --save redux-saga
-
Modify
people.reducer.ts
. Add loading (set to false) in INITIAL_STATE. Create 3 action types: FETCH_PEOPLE_START, FETCH_PEOPLE_SUCCESS, FETCH_PEOPLE_ERROR.Partial Snippet:
const INITIAL_STATE = { list: [], loading: false, }; // ... code redacted case 'FETCH_PEOPLE_START': return { ...state, loading: true, }; case 'FETCH_PEOPLE_SUCCESS': return { ...state, loading: false, list: action.payload, }; case 'FETCH_PEOPLE_ERROR': return { ...state, loading: false, error: action.payload, };
-
Create 3 actions corresponding to previous step in
people.action.ts
.Snippet:
export const fetchPeopleStart = () => ({ type: 'FETCH_PEOPLE_START', }); export const fetchPeopleSuccess = (list: []) => ({ type: 'FETCH_PEOPLE_SUCCESS', payload: list, }); export const fetchPeopleError = (error: any) => ({ type: 'FETCH_PEOPLE_ERROR', payload: error, });
-
Create the People saga under
/src/redux/people/people.saga.ts
.import { call, put, takeLatest } from 'redux-saga/effects'; import { FetchHelper } from '../../services/FetchHelper'; import { SwapiUrls } from '../../services/SwapiUrls'; import { fetchPeopleError, fetchPeopleSuccess } from './people.action'; // This is a worker function responsible for fetching API and bind data to state if success or bind error if fail function* fetchPeopleAsync(): Generator<any, any, any> { try { const people = yield call(FetchHelper.getAll, SwapiUrls.PEOPLE); yield put(fetchPeopleSuccess(people)); } catch (e: any) { yield put(fetchPeopleError(e.message || e)); } } // This is a watcher function, once FETCH_PEOPLE_START is received, it will trigger fetchPeopleAsync export function* watchFetchPeopleStart() { yield takeLatest('FETCH_PEOPLE_START', fetchPeopleAsync); }
-
Create root-saga.ts under
/src/redux/root-saga.ts
. This will be the place we will aggregate multiple sagas parallely. For now we have 1 saga, but should we have more we just add here.import { all, call } from 'redux-saga/effects'; import { watchFetchPeopleStart } from './people/people.saga'; export default function* rootSaga() { yield all([call(watchFetchPeopleStart)]); }
-
Add redux saga middleware in our
store.ts
.6.1 Import createSagaMiddleware
import createSagaMiddleware from 'redux-saga'
6.2 Import rootSaga
import rootSaga from './root-saga';
6.3 Create sagaMiddleware and add it in middlewares.
const sagaMiddleware = createSagaMiddleware(); const middlewares: any = [sagaMiddleware];
6.4 Run the saga
sagaMiddleware.run(rootSaga);
6.5 You may refer to
store.ts
source code in module-6 branch of this repository for the complete code.
-
Modify
PeopleList.tsx
, importfetchPeopleStart
action and replace theuseEffect
function like this:useEffect(() => { dispatch(fetchPeopleStart()); }, []);
-
Save and reload the app. If everything is setup correctly, the data will load as usual. If not, refer to
module-6
branch and check if you missed anything.
- Since
setPeople
action orSET_PEOPLE_LIST
is no longer in use, it has been removed frompeople.action.ts
andpeople.reducer.ts
.
- Modify
PeopleList.tsx
, useuseSelector
hook to get theloading
state and show a loading indicator. To do this, you can enclose the area you want to cover with<Skeleton loading={loading} avatar active></Skeleton>
. - Refresh People page and you should see a shimmer on load before the data is loaded.
Challenge yourself to create the saga for Starships. The solution for this exercise will be available in Module 7.
- Loading of people and starships is now done in
MyLayout.tsx
since this is the master page. Whether you start reloading from home, people or starships, data will still load. - Added loading indicator in
RandomPerson.tsx
- Starships saga
- Added unit tests
Loading has been prevalent in components that require fetching API. Since this is common, let's create a wrapper component or a higher order component (HOC) that will inject the required loading state and render the loading indicator.
You can check out /src/components/wrapper/withLoading.tsx
, /src/components/wrapper/withLoadingPeople.tsx
, /src/components/wrapper/withLoadingStarships.tsx
for the code to accomplish this.
withLoading
renders the Skeleton loading indicator when loading property is true.
withLoadingPeople
extracts the loading state from people reducer and connects the wrapped component to redux. People components that need loading indicator should use this.
withLoadingStarships
does the same but for Starships components.
Using these HOC is simple, in RandomPerson.tsx
, we modify the exported component and surround it with the HOC function such as: export default withLoadingPeople(RandomPerson);
.
-
Added dev dependency on redux-saga-test-plan. To install it:
npm i --save-dev redux-saga-test-plan
-
Samples to check from
module-7
branch:/src/redux/people/people.reducer.test.ts
/src/redux/people/people.saga.test.ts
Read more on redux-saga-test-plan: https://github.com/jfairbank/redux-saga-test-plan
-
Running the tests
npm run test -- --coverage npm run test -- --coverage src/redux/people/people.reducer.test.ts npm run test -- --coverage src/redux/people/people.saga.test.ts
-
Added dev dependency to sinon. To install it:
npm i --save-dev sinon
npm i --save-dev @types/sinon
-
Samples to check
/src/components/widget/Gravatar.test.tsx
- stateless component, easy to test/src/components/widget/RandomPerson.test.tsx
- useSelector hook should be mocked, Math.random function also in order to return a deterministic result -
Run the tests
npm run test -- --coverage src/components/widget/Gravatar.test.tsx npm run test -- --coverage src/components/widget/RandomPerson.test.tsx
- Study the tests done for people reduer and saga and create the tests for starship redux.
- Create unit tests for other components
-
Create a Squad widget and display in home base
As a captain, you are building a squad of 10 people. Create a way to add or remove people in your squad, once the slot has been filled up it will not be possible to add more people. Once the person is in your squad he can no longer be recruited unless you remove him.
-
Create a Hangar widget and display in home base
You have a hangar with a capacity for 10 starships, create a way to add or remove starships in your hangar. You can have mutiple starships of the same model, but you are limited to 10. By the way you can't add Death Star, dream on.
This module completes the SW Manager Portal. Squad and Hangar widgets have been added. Home Base state is also added to manage your resources.
You can study the code in module-8
branch to learn more.
-
Reselect
- a good way to derive the state values. Read more: https://github.com/reduxjs/reselectnpm i --save reselect
Feel free to enhance and add more features. May the force be with you.