@redux-ts-starter-kit/slice

reduce redux boilerplate

Usage no npm install needed!

<script type="module">
  import reduxTsStarterKitSlice from 'https://cdn.skypack.dev/@redux-ts-starter-kit/slice';
</script>

README

@redux-ts-starter-kit/slice Build Status

A simple set of tools to make using Redux easier

Installation

npm install @redux-ts-starter-kit/slice

Note

This package only includes tools for simplifying the creation of slice reducers, it does not ship with redux or any of the tools to automate the generation of a redux store with enhancers and middleware.

If you require such tools see @redux-ts-starter-kit/core

Purpose

One of the biggest complaints developers have with redux is the amount of boilerplate and new concepts they have to learn to use it. @redux-ts-starter-kit/slice attempts to simplify the boilerplate by automatically configuring actions, reducers, and selectors. The way it works is @redux-ts-starter-kit/slice will take a list of functions that correspond to how state should be updated and then create action types, action creators, and basic selectors for the developer to use. This library tries to not make too many assumptions about how developers use redux. It does not do anything magical, simply automates the repetitive tasks with redux.

Under the hood every reducer created by @redux-ts-starter-kit/slice leverages immer to update the store, which means reducers are allowed to mutate the state directly.

Features

  • First-class typescript support.
  • Automatically creates actions, reducer, and selector(s).
  • Reducers leverage immer which makes updating state easy.
  • When stringifying action creators they return the action type.
  • Helper functions for manually creating actions and reducers.
  • Reducers do not receive entire action object, only the payload which simplifies things.
  • Type inferrence and generation, minimizing the need to manually define types.
  • Create computed selectors that are automatically memoized using the wonderful memoize-state lib.

Inspirations

This library was heavily inspired by autodux, robodux and redux-starter-kit.

Example

import { createSlice } from '@redux-ts-starter-kit/slice';
import { createStore, combineReducers } from 'redux';

const counterSlice = createSlice({
  initialState: 0,
  cases: {
// the type of `state` param is automatically inferred from the initialState
    increment: state => state + 1,
    incrementBy: (state, payload: number) => state + payload,
    decrement: state => state - 1,
    decrementBy: (state,payload: number) => state - payload,
    multiply: (state, payload: number) => state * payload,
  },
});

interface User {   // state slice/initial state interface
  name: string;
}

const initialUserState: User = {
  name: '',
}

const userSlice = createSlice({
  initialState: initialUserState,
  cases: {
    setUserName: (state, payload: string) => {
      state.name = payload;
    },
  }
})

const rootReducer = combineReducers({
    counter: counterSlice.reducer,
    user: userSlice.reducer,
  })

const store = createStore(rootReducer)
// After creating the store the `mapSelectorsTo` util can be used to map the selectors to the correct path.

const counterSelectors = counterSlice.mapSelectorsTo('counter');
const userSelectors = userSlice.mapSelectorsTo('user');

store.dispatch(counterSlice.actions.increment());
// New State -> { counter: 1, user: { name: '' } }

store.dispatch(counterSlice.actions.incrementBy(10));
// New State -> { counter: 11, user: { name: '' } }

store.dispatch(counterSlice.actions.multiply(3));
// New State -> { counter: 33, user: { name: '' } }

store.dispatch(counterSlice.actions.decrement());
// New State -> { counter: 32, user: { name: '' } }

store.dispatch(counterSlice.actions.decrementBy(8));
// New State -> { counter: 26, user: { name: '' } }

console.log(`${counterSlice.actions.decrement}`);
// -> 'decrement'

store.dispatch(userSlice.actions.setUserName('eric'));
// New State -> { counter: 6, user: { name: 'eric' } }

const state = store.getState();
console.log(counterSelectors.selectSlice(state));
// -> 6
console.log(userSelectors.selectSlice(state));
// -> { name: 'eric' }

console.log(userSelectors.name(state));
// -> 'eric'

createSlice API

A function that accepts an initial state and an object whose methods are case reducers, and automatically generates action creators, action types, and selectors that correspond to the reducers and state.

The reducers will be wrapped in the createReducer() utility, and so they can safely "mutate" the state they are given.

createSlice also accepts the following optional arguments:

  • typeOverrides
  • computed.
function createSlice({
    // A object of function that will be used as cases for the returned reducer,
    // is used to generate action creators that trigger the corresponding case
    cases: {
      [name: string]: (state: S, payload: P) => S | void | undefined
    },
    // The initial Slice State, same as normal reducer
    initialState: S,
})

General Usage

import { createSlice } from '@redux-ts-starter-kit/slice';

  interface FormState {
    name: string;
    surname: string;
    middlename: string;
  }


  const formInitialState: FormState = {
    name: '',
    surname: '',
    middlename: ''
  };

  const formSlice = createSlice({
    initialState: formInitialState,
    cases: {
      setName: (state, payload: string) => {
        state.name = payload;
      },
      setSurname: (state, payload: string) => {
        state.surname = payload;
      },
      setMiddlename: (state, payload: string) => {
        state.middlename = payload;
      },
      resetForm: () => formInitialState
    },
  });

Arguments

createSlice accepts a single argument object with the following fields:

initialState required

The state that the reducer is initialized with, same usage as standard reducer.

cases required

An object whose methods represent the cases the generated reducer handles, can be thought of as the equivalent of switch-case statements in a standard reducer.

  • Each method is equivalent to a standard switch case statement in a standard reducer.
  • Each method receives the state as it's first argument followed by the action payload.
  • Each method can be thought of as a caseReducer.
  • Each method can mutate the state directly as it uses immer behind the scenes to make it immutable.
  • Each method is used to make an action creator of the same name.

note: the second argument does not have to be called payload even though it is in actuality the action payload. You can name it whatever you want

type Todo = { title: string, completed: boolean };

const todoSlice = createSlice({
  initialState: [] as Todo[],
  cases: {
    addTodo: (state, title: string) => {
      state.push({ title, completed: false })
    },
    deleteTodo: (state, index: number) => {
      state.splice(index, 1);
    },
    setCompleted: (state, index: number) => {
      state[index].completed = true;
    },
    deleteAllTodos: () => [],  // sets the state to an empty array
  }
})

Note:

  • No need to type cast the state argument, its type is automatically inferred from the initialState field.

  • The returned action creators accept only a single argument as payload, i.e a case in the form (state,payload1,payload2)=>{} is invalid. If you need to pass multple arguments use an object or array to pass them.

typeOverrides not required

Type overrides allow the user to override the type which case reducers respond to, which by default is simply the name of the case reducer.

i.e the addTodo case reducer in cases: { addTodo: (state, payload) => {//...}, } would by default respond to actions of type 'addTodo' e.g. { type: 'addTodo', payload: 'Jog' }.

This can be changed thanks to the typeOverrides option.

For example to change the type to 'ADD_A_TODO' instead see the example below.

It should be noted that the action creators createSlice generates automatically account for typeOverrides

type Todo = { title: string, completed: boolean };

const todoSlice = createSlice({
 initialState: [] as Todo[],
 cases: {
   addTodo: (state, title: string) => {
     state.push({ title, completed: false })
   },
   deleteTodo: (state, index: number) => {
     state.splice(index,1);
   },
   setCompleted: (state, index: number ) => {
     state[index].completed = true;
   },
   deleteAllTodos: () => [],
 },
 typeOverrides: {
   addTodo: 'ADD_A_TODO',
 }
})

console.log(todoSlice.actions.addTodo('Jog in the morning!'))
// `{ type: 'ADD_A_TODO', payload: 'Jog in the morning!'}`
// the default type is overriden!

console.log(todoSlice.actions.deleteTodo(1))
// `{ type: 'deleteTodo', payload: 1 }`
// the default type is untouched

This feature is also useful for cases where you need to change the type due to some reason like clashes but you don't want to have to rename all your action creators and their imports.

computed not required

Computed selectors for the slice, will be memoized using memoize-state lib. These selectors will only be recomputed when one of the used endpoints of the state changes, see the github page here for more details.

The computed selectors can be accessed with the mapSelectorsTo util alongside the regular selectors.

note: For typescript users only, if using this to access other selectors, ReturnType should be explicit to prevent typescript from mistaking the type as being circular and giving strange errors. Javascript users have nothing to worry about

type Todo = { title: string, completed: boolean };

const todoSlice = createSlice({
  initialState: [] as Todo[],
  computed: {
    getCompletedTodos(state) {
      return state.filter(todo => todo.completed)
    },
    getCompletedTodosLength(state): number {
      return this.getCompletedTodos(state).length;
    },
  }
  cases: {
    addTodo: (state, title: string) => {
      state.push({ title, completed: false })
    },
    deleteTodo: (state, index: number) => {
      state.splice(index,1);
    },
    setCompleted: (state, index: number ) => {
      state[index].completed = true;
    },
    deleteAllTodos: () => [],
  },
  typeOverrides: {
    addTodo: 'ADD_A_TODO',
  }
})

Return value

createSlice return an object with the following fields

reducer

A reducer function, works exactly the same as a standard reducer

actions

An object of action creators with the same name as the corresponding case. You can see this in action in the todoSlice example above, it's actions object has the following type signature


// type signature of `todoSlice.actions`
{
  addTodo: {
    (payload: string): PayloadAction<string, 'ADD_A_TODO'>;
    type: 'ADD_A_TODO';
  };
  deleteTodo: {
    (payload: number): PayloadAction<number, 'deleteTodo'>;
    type: 'deleteTodo';
  };
  setCompleted: {
    (payload: number): PayloadAction<number, 'setCompleted'>;
    type: 'setCompleted';
  };
  deleteAllTodos: {
    (): PayloadAction<undefined, 'deleteAllTodos'>;
    type: 'deleteAllTodos';
  };
}

mapSelectorsTo

A utility function that receives a path(s) arg and generates selector(s) mapped to that path, always includes a selector called selectSlice that selects it's slice state from the redux state, if the initial state is an object additional selectors are generated with the same names as the corresponding initial state keys.

Includes computed selectors if present.

note: it can receive multiple path arguments in the event of a deeply nested state slice

E.g

import { createSlice } from '@redux-ts-starter-kit/slice';
import { createStore, combineReducers } from 'redux';


const namesInitialState = {
  firstName: '',
  LastName: '',
  middleName: '',
};

const namesSlice = createSlice({
  initialState: namesInitialState,
  computed: {
    getFullName: state =>
      `${state.firstName} ${state.middleName} ${state.LastName}`,
  }
  cases: {
    setFirstName: (state, payload: string) => {
      state.firstName = payload;
    },
    setLastname: (state, payload: string) => {
      state.lastName = payload;
    },
    setMiddlename: (state, payload: string) => {
      state.middleName = payload;
    },
    resetForm: () => namesInitialState,
  },
});

const detailsReducer = combineReducer({
  names: namesSlice.reducer,
  other: //...,
})

const rootReducer = combineReducers({
  todos: //...,
  counter: //...,
  details: detailsReducer,
});

const store = createStore(rootReducer);

const namesSelectors = formSlice.mapSelectorsTo('details', 'names');

// later...

const sampleReduxState = {
  todos: [
    { title: 'Jog in the morning!', completed: false }
  ],
  counter: 15,
  details: {
    names: {
      firstName: "John",
      LastName: "Doe",
      middleName: "Wayne",
    },
    others: {
      age: 28,
      profession: 'Programmer',
      //...
    },
  },
}

console.log(namesSelectors.selectSlice(sampleReduxState));
// {
//   firstName: "John",
//   LastName: "Doe",
//   middleName: "Wayne",
// }

// note: the `firstName`, `middleName` and `lastName` selectors where automatically created based on the initialState given for the slice.
console.log(namesSelectors.firstName(sampleReduxState));
// "John"

console.log(namesSelectors.middleName(sampleReduxState));
// "Wayne"

console.log(namesSelectors.lastName(sampleReduxState));
// "Doe"

// computed selectors are included.
console.log(namesSelectors.fullName(sampleReduxState));
// "John Wayne Doe"

// sample useage in mapStateToProps

const mapStateToProps = (state: ReduxState) =>({
  firstName: namesSelectors.firstName(state),
  middleName: namesSelectors.middleName(state),
  lastName: namesSelectors.lastName(state),
  fullName: namesSelectors.fullName(state),
})

export default connect(mapStateToProps)(NameComponent);

Other Exports

createAction/createTypeSafeAction

This is the helper function that createSlice uses to create an action. It is also useful to use when not using createSlice because when stringifying the function it will return the action type. This allows developers to not have to worry about passing around action types, instead they simply pass around action creators for reducers, sagas, etc.

import { createAction } from '@redux-ts-starter-kit/slice';

const increment = createAction('INCREMENT');
// or for typescript users who need strictly typed action creators
const increment = createTypeSafeAction('INCREMENT')<number>();

console.log(increment);
 ->> 'INCREMENT'
console.log(increment(2));
->> { type: 'INCREMENT', payload: 2 };

const storeDetails = createAction('STORE_DETAILS');
// or for ts users
const storeDetails = createTypeSafeAction('STORE_DETAILS')<{name: string; surname: string}>();
console.log(storeDetails);
->> 'STORE_DETAILS'
console.log(storeDetails({ name: 'John', surname: 'Doe' }));
 ->> { type: 'STORE_DETAILS', payload: {name: 'John', surname: 'Doe'} };

createReducer

This is the helper function that createSlice uses to create a reducer. This function maps action types to reducer functions. It will return a reducer.

import { createReducer, createAction } from '@redux-ts-starter-kit/slice';

const multiply = createTypeSafeAction('MULTIPLY')<number>()

const counter = createReducer({
  initialState: 0,
  cases: {
    INCREMENT: (state) => state + 1,
    DECREMENT: (state) => state - 1,
    [multiply.type]: (state, payload) => state * payload,
  }
});

console.log(counter(2, { type: 'MULTIPLY': payload: 5 }));
// -> 10

// OR

console.log(counter(2, multiply(5)));
// -> 10