-
Notifications
You must be signed in to change notification settings - Fork 12
Redux React Guidelines
List of standards:
- IDs should be strings
- reducers should be exported with name
initState()
- .... please add more as needed
- Packages
- Redux Intro
- Folder Structure
- Redux Store
- Actions
- Reducers
- Selectors
- Testing
- @types/react-redux
- redux
- redux-devtools-extension
- redux-logger
- redux-thunk
- reselect
- re-reselect?
- redux-api-middleware
- normalizr?
The official documentation of Redux says: "Redux is a predictable state container for JavaScript applications.
As user interactions on a web page become more and more complex, it becomes more challenging to manage the state of the web pages after each event. Additionally, user interactions tend to have cascading effects, changing the state of multiple nested components, within the page. This can be difficult to maintain as an application grows in scale.
Redux is a tool that makes managing the state of an application easier, by managing the entire applications data in it's own container, also known as the Redux Store.
The Redux store helps developers manage the state of an application by keeping the entire state of the application in one place. Each component can then access the store directly for data, or make changes to the store after an event (eg. mouse click on button, when component is mounted).
The redux store holds the state tree, which is just an object. The state tree holds the entire state of your application. The state tree can be structured any way you want. However, we want to make sure that we structure our store in way that is scalable, maintainable, and highly performant.
Redux recommends managing relational or nested data in the store, by treating a portion of the store like a database, by keeping the data in a normalised shape.
Definition: transforming the schema of a database to remove redundant information.
Normalisation is ideal process to adopt, because of its many benefits such as reducing data redundancy and improving data integrity.
Why do we need to normalise data in the state tree?
-
Every item has only ONE location where it can be referred too. This is very important in case you need to use a data item in multiple areas of the application. You can be confident knowing that there is only one source of truth for this data.
-
By keeping data filed by their ID (IDs MUST always be a
string
) in a Map structure, it's easier to traverse through and find what your looking for. Also, it's easier/effective to traverse a flatter structure, then a deeply nested structure. -
In React, components a re-render is triggered whenever the state is updated. When the data is normalised this helps prevent unnecessary re-renders of unrelated UI components, since the data is being .
eg.
{
entities: {
catOwners: {
byId: {
'1': {
id: '1',
name: 'Chris'
},
'2': {
id: '2',
name: 'Josh'
},
'3': {
id: '3',
name: 'Grant'
}
},
allIds: [1, 2, 3]
},
cats: {
byId: {
'1': {
id: '1',
name: 'Tracker',
catOwnerId: 1
},
'2': {
id: '2',
name: 'Casey'
catOwnerId: 1
}
...
},
allIds: [1, 2, ... , N]
}
},
}
Please read Redux docs on normalizing state
This article covers all the benefits and strategies to building normalized state in more detail.
The Redux state tree should contain an entities
object. This will hold all of the data objects that will be referenced. They should be sorted by their type (jobs, skills, criteria, etc.), and should hold the data of each item, where the key is equal to the ID of the data item.
eg.
jobs: {
'1': {
id: 1,
...
},
}
We can also split jobs
into two more sections: byId
and allIds
. This makes it easier to grab the entire set of jobs.
jobs: {
byId: {
'1': {
id: 1,
...
},
...
'N': {
id: N,
...
}
},
allIds: [1, ..., N]
}
Again, all the data types should be referenced from entities
, it is our single source of truth.
Each data type (eg. jobs, skills, etc.) can be utilized in various ways. Multiple different components or pages will be using the same data in different ways. Whenever a new viewport needs a certain set of data and a certain state, you should create a new branch in the state tree that will hold the data. This branch will reference data from the entity, as necessary. Then any extra metadata (eg. errorMessage, loading, etc.) will be added into a separate object, for example ui
.
eg.
state = {
entities: { ... }
assessmentPlan: {
job: 'id4',
skills: [ 'id1', ..., 'idN' ],
assessments: [ 'id1', ..., 'idN' ],
ui: {
isLoading: false,
errorMessage: ""
}
}
}
NOTE: The ui
object holding the metadata specific to the domain can also be broken down into its corresponding entity?
Instead of converting all the data from the backend API's all the time to fit the structure wanted, there is a library called normalizr
which does all the heavy lifting.
An action is a plain object. It is the first of three steps in the redux cycle, and it represents an intention to manipulate the state tree. The only way to add/edit/delete data in the store, is through an action.
Actions must have a type
field. Types should defined using a constant (eg. ACTION_ADD_JOB
, ACTION_DELETE_JOB
), and imported from another module. Make sure to make the type field as descriptive as possible.
Actions may include any other properties needed. However, it is recommended by the Flux Standard Action that the only other properties are the following:
- type
- payload
- meta
- error
eg.
//src/store/job/types.ts
import {Job} from './model/types';
const ADD_JOB = 'ADD_JOB';
{
type: ADD_JOB,
payload: Job
}
ALTERNATIVE?: Many actions may be taking place at once such as authentication actions, locale actions, entity actions. Another option may be to namespace actions. This also makes it easier for debugging in the dev tools. (You can also use an enum to have all actions related to an entity in one place).
eg.
//src/store/job/types.ts
import {Job} from './model/types';
export enum JobActions {
ADD_JOB = '@@jobs/ADD_JOB';
}
{
type: JobActions.ADD_JOB,
payload: Job
}
Exactly how it sounds, an action creator is a function that creates an action. Therefore, an ACTION is just an object containing information (type, payload), and an ACTION CREATOR is a function that creates an action.
An action creator does NOT dispatch the action. To dispatch an action, and cause a change in the state, you must call the store's dispatch()
function.
eg.
//src/store/job/types.ts
const ADD_JOB = 'ADD_JOB';
// First define action creator interface
interface AddJobAction {
type: typeof ADD_JOB,
payload: {
id
job: Job
}
}
// Our ADD_JOB action creator
export const addJob = (id: number, job: Job): AddJobAction => {
return {
type: ADD_JOB,
payload: {
id,
job
}
};
};
// OR USE SHORTHAND METHOD FOR RETURNING OBJECT, LIKE BELOW?
export const addJob = (id: number, job: Job): AddJobAction => ({
type: ADD_JOB,
payload: {
id,
job
}
});
...
// export all actions under single variable
export type JobActionTypes = AddJobAction | EditJobAction | DeleteJobAction ...;
TODO: Explain how to Connect store to React component (mapStateToProps, mapDispatchToProps), and dispatch the action creator.
When testing an action creator we are looking to validate the correct action creator was called and the right action was returned. Actions that do not contain any arguments aren't that useful to test. However, actions with arguments should be checked to see if the expectedAction
equals the original.
Steps:
-
Create a test file. It should have the same name as the actions folder except with a ".test.ts" extension. In this case it will be
JobActions.test.ts
. -
We want our tests to be organized into groups, so use the
describe(name, fn)
function. It creates a block that groups together our related tests.
```js
//src/store/job/jobActions.test.ts
describe('Job Actions', () => {
...
})
```
- Now create a test for the action creator you will implement in the future, or one that you have already implemented. All you need is the
it(name, fn, timeout)
method which runs the test. The name should start with "should create an action to...".
```js
//src/store/job/jobActions.test.ts
describe('Job Actions', () => {
it('should create an action to add a job', () => {
...
})
});
```
- Now, create any mock data needed for any arguments other than
type
(thetype
argument should be imported from actions file). Then create theexpectedAction
action object with the mock data arguments.\
```js
//src/store/job/jobActions.test.ts
import { ADD_JOB } from 'jobActions.ts';
it('should create an action to add a job', () => {
const job = { id: 5, title: 'WebDeveloper' ... }
const expectedAction = {
type: typeof ADD_JOB,
payload: {
id
job
}
...
});
```
- Lastly, we will use jests the
expect()
method. This method comes with a variety of 'matchers' that will let you validate the action creators. See docs for more onexpect()
.
```js
//src/store/job/jobActions.test.ts
// import job type
import { Job } from 'models/types';
// import action and action creator
import { ADD_JOB } from 'store/jobs/types';
import { addJob } from 'store/jobs/actions';
import { Action } from "../createAction";
describe('Job Actions', (): void => {
it('should create an action to add a job', (): void => {
const job: Job = { id: 5, title: 'WebDeveloper' ... }
const expectedAction: AddJobAction = {
type: typeof ADD_JOB,
payload: {
id
job
}
}
expect(addJob(job)).toEqual(expectedAction);
});
});
```
NOTE: If you need the same set data in multiple tests then you can use one of Jests Global methods, such as beforeEach(fn, timeout)
. It simply runs a function before each test. Therefore, you can create all the variables, and assign them a value within the beforeEach()
method.
eg.
//src/store/job/jobActions.test.ts
let id, job, updates;
describe('Job Actions', (): void => {
beforeEach((): void {
id = 5;
job = { id: 5, title: 'WebDeveloper' ... };
updates = { title: 'Java Developer', ...}
});
it('should create an action to add a job', (): void => {
const expectedAction: AddJobAction = {
type: typeof ADD_JOB,
payload: {
id,
job
}
}
expect(addJob(id, job)).toEqual(expectedAction);
});
it('should create an action to edit a job', (): void => {
const expectedAction: EditJobAction = {
type: typeof EDIT_JOB,
payload: {
id,
updates
}
}
expect(editJob(id, updates)).toEqual(expectedAction);
});
});
An async action is used when an action creator needs to perform some extra logic before dispatching, for example:
- API call,
- read the current state,
- etc.
Async actions return a value that is sent to a dispatching function. It will then be transformed by middleware into an action, or a series of actions. Next, it will be sent to the base dispatch()
function, and then consumed by the reducer.
We can use redux-thunk
middleware for handling asynchronous API calls before dispatching an action (eg. FETCH_REQUESTED
, FETCH_SUCCEEDED
, FETCH_FAILED
). It allows you to return a function instead of an action object, this way we can add in any logic before or after dispatching an action, or multiple actions.
However, as an application grows, this requires writing a lot of boilerplate. The solution is the package redux-api-middleware
, which is a Redux middleware for calling an API. It comes with almost everything needed out-of-the-box.
Please check out the docs for information on usage and testing.
----------- IN PROGRESS ----------
Reducers are the heart and soul of Redux. When an Action is dispatched, the corresponding reducer determines how the state tree will be manipulated.
Actions describe what happened. Reducers respond by manipulating the state.
A reducer is just a function that accepts two things:
- the current state,
- and an action.
Reducers calculate a new state given the previous state and the dispatched action. Reducers MUST be a pure function. It should always have the same outputs for the same given inputs. Using pure functions allows for features like time travel debugging and hot reloading. Redux provides a list of things you should never do within a reducer:
- Mutate its arguments
- Perform side effects like API calls and routing transitions;
- Call non-pure functions eg. Date.now() or Math.random().
- Start by creating the initial state. The shape of the initial state will depend on the entities being referenced, and what UI metadata needs to be stored.
Question: When do we create a new reducer?
a. For an entity b. For a specific domain/window/page c. ?
-
Entity Reducers:
- Start by creating the interface for the Entity State. Then create a function returning an object with the Entity State interface.
//src/store/job/jobReducer.ts export interface ByIdState { byId: { [id: number]: Job } } export interface AllIdsState { allIds: number[]; } // structure if the Job State export interface JobState { jobs: { byId: ByIdState allIds: AllIdsState } } export const initState = (): JobState => ({ jobs: { byId: {}, allIds: [] } });
- Next create two reducers:
${entity}byIds
,${entity}allIds
. Again, the reducer function takes two arguments, the current state and an action. We can use the ES6 feature default parameters to set the initial state value.
The reason were splitting the reducer into two is because it makes it easier to access the job items in
byId
object, and theallIds
array.//src/store/job/jobReducer.ts export const jobsById = ( state = {}, action: JobAction ): ByIdState => { ... } export const allJobs = ( state = [], action: JobAction ): AllIdsState => { ... }
- Conventionally, a
switch
statement is used to evaluate which Action was dispatched. Therefore, create acase
clause for all possible actions.
Each case should contain the logic that will manipulate the state. However, it's best to place the code into its own function, this makes the reducer easier to navigate through.
IMPORTANT: Remember we must not mutate that state directly. A new brand new state should be created with the contents of the current state, along with any mutations to data (eg. add, edit, delete) from the action.
//src/store/job/jobReducer.ts import { addJobAction } from './jobActions'; // This adds a job to the byIds object const addJob = (state: JobState, action: addJobAction) => { const { payload: Job } = action; const { id } = payload; // spread the payload properties into job object const job = { ...payload }; // returning a NEW object return { ...state, [id]: job } } // adds id to list of jobs ids const addJobId = (state: JobState, action: addJobAction) => { const { payload: Job } = action; const { id } = payload; // returning a NEW array return [ ...state, id ]; } export const jobsById = ( state = {}, action: JobAction ): JobState => { switch(action.type) { case ADD_JOB: // seperate code into function addJob(state, action); default: return state; } } export const allJobs = ( state = [], action: JobAction ): JobState => { switch(action.type) { case ADD_JOB: // seperate code into function addJobId(state, action); default: return state; } }
- Lastly, at the bottom combine the two reducers into one using
combineReducers(reducers)
method, provided by Redux. R. Make sure to follow the nameing convention${entity}Init
.
//src/store/job/jobReducer.ts ... export const jobsInit = (): Reducer<JobState> => ({ combineReducers({ byId: jobsById allJobs: allJobs }); });
--- NEEDS UPDATE ---
Reducers are responsible for "writing" to the redux store. Therefore, it is important to make sure the operations (eg. CRUD, metadata) happening, are manipulating the state correctly.
Steps:
-
Create a test file. It should have the same name as the reducers folder except with a ".test.ts" extension. In this case it will be
JobReducers.test.ts
. -
We want our tests to be organized into groups, so use the
describe(name, fn)
function. It creates a block that groups together our related tests.
```js
//src/store/job/jobReducers.test.ts
describe('Job Reducers', () => {
...
})
```
- Now create a test for the action creator you will implement in the future, or one that you have already implemented. All you need is the
it(name, fn, timeout)
method which runs the test. The name should start with "should setup...".
```js
//src/store/job/jobReducers.test.ts
it('should setup add job reducer', () => {
...
})
```
--- NEEDS UPDATE ---
- "A 'selector is simply a function that accepts Redux state as an argument and returns data that is derived from that state."
Selectors provide a query layer in front of the Redux state. This allows us to keep the Redux store separated from components, and makes it more reusable. For example, lets say we pull data from the store directly from multiple components throughout the app. If later down the line the structure of the data store needs to be changed, it will require locating every spot in the app where the data is being pulled from, and changing each individually. The solution... Selectors.
Example.
This is an example of a selector showing its capabilities. The selector can be made to filter or sort data.
const sortApplicantsByPrioritySelector(applicants) => {
// filters/sort jobs according to certain criteria and skills
...
}
The selector is commonly used within mapStateToProps
.
const mapStateToProps = (
state: RootState
) => {
return {
job: sortApplicantsByPrioritySelector(state.entity.applicants);
}
}
Reselect is a simple selector library for Redux. It comes built-in with all the tools needed. Most importantly, creating Memoized Selectors (caching data relative to arguments, and only recomputes the data if new arguments are set.) will improve the performance of the overall application.
Provides:
- Selectors can compute derived data, allowing Redux to store the minimal possible state.
- Selectors are efficient. A selector is not recomputed unless one of its arguments changes. (Memoized Selector)
- Selectors are composable. They can be used as input to other selectors.
Please read Reselect docs for information on usage.
---- IN PROGRESS -----