Skip to content
This repository has been archived by the owner on Aug 11, 2022. It is now read-only.

Redux React Guidelines

tristan-orourke edited this page Oct 2, 2020 · 5 revisions

Redux+React Paradigm

List of standards:

  • IDs should be strings
  • reducers should be exported with name initState()
  • .... please add more as needed

Contents

  1. Packages
  2. Redux Intro
  3. Folder Structure
  4. Redux Store
  5. Actions
  6. Reducers
  7. Selectors
  8. Testing

Main Packages

Redux Package Extensions (Middleware & Enhancers)

Testing Packages

What is Redux?

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.

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.

Normalisation

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.

Entities

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.

Domain Data

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?

Normalizing data

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.

"Normalizr is a small, but powerful utility for taking JSON with a schema definition and returning nested entities with their IDs, gathered in dictionaries."

Actions, Action Creators, and Async Actions

What is an Action?

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
}

What is an Action Creator?

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.

Testing Action Creators

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:

  1. 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.

  2. 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', () => {
    ...
  })
```
  1. 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', () => {
    ...
  })
});
```
  1. Now, create any mock data needed for any arguments other than type (the type argument should be imported from actions file). Then create the expectedAction 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
    }
    ...
  });
```
  1. 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 on expect().
```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);
    });
  });

What is an Async Action?

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.

Testing Async Action Creators

----------- IN PROGRESS ----------

Reducers

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().

Create a Reducer

  1. 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:

    1. 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: []
        }
      });
    1. 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 the allIds array.

      //src/store/job/jobReducer.ts
    
      export const jobsById = (
        state = {},
        action: JobAction
      ): ByIdState => {
        ...
      }
    
      export const allJobs = (
        state = [],
        action: JobAction
      ): AllIdsState => {
        ...
      }
    1. Conventionally, a switch statement is used to evaluate which Action was dispatched. Therefore, create a case 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;
        }
      }
    1. 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 ---

Testing Reducers

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:

  1. 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.

  2. 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', () => {
    ...
  })
```
  1. 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 ---

Selectors

What are Selectors

  • "A 'selector is simply a function that accepts Redux state as an argument and returns data that is derived from that state."

Why should we use Selectors?

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

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 -----

Testing Selectors

React-Redux

MapStateToProps

MapDispatchToProps

Connect

Sources