1.2.0 • Published 4 years ago

@xornot/redux-case v1.2.0

Weekly downloads
32
License
ISC
Repository
github
Last release
4 years ago

@xornot/redux-case

Redux reducer and action builders with type-safety. TypeScript definitions are included.

Compared to Others

  • Tiny - Less than 1kB minified and GZipped (according to BundlePhobia).
  • Minimal - No dependencies, no bells, no whistles.
  • Simple - Very small API, no magic, and standard Redux patterns.
  • Un-opinionated - Just as flexible as a vanilla reducer and handmade actions. Do or don't use flux standard actions, use any Redux middleware, layout your Redux file structure any way you like, etc.
  • Type-safe - Typed actions and action objects.
  • Extensible - Inject middleware at the reducer scope, and add reducer cases lazily.

Getting Started

Install with yarn:

yarn add @xornot/redux-case

Install with npm:

npm add @xornot/redux-case

First, create some actions.

import { createAction } from "@xornot/redux-case";

// This action takes a single number parameter, and returns an action object
// with the required "type" property and a "value" number property.
export const set = createAction("counter/set", (value: number) => {
  return { value };
});

// This action has no parameters, and returns an action object with only the
// required action type property.
export const increment = createAction("counter/increment");

Note: These "actions" are factory functions which use the callback to create an action object.

Next, create a reducer with some cases to handle your actions.

import { createReducer } from "@xornot/redux-case";
import { set, increment } from "./actions";

export const counter = createReducer(
  // The initial state value.
  { count = 0 }
)
  // Handle the "set" action. The action.type will be "counter/set", and
  // the action.value property will be a number.
  .case(set, (state, action) => {
    return {
      ...state,
      count: action.value
    };
  })
  // Handle the "increment" action. The action.type will be
  // "counter/increment", and there will not be any additional properties.
  .case(increment, (state, action) => {
    return {
      ...state,
      count: state.count + 1
    };
  });

Finally, use the reducer like any other Redux reducer.

import { createStore } from "redux";
import { counter } from "./counter";
import { set, increment } from "./actions";

export const store = createStore(counter);

// Invoke the action to get the action object that is dispatched.
store.dispatch(set(5));
store.dispatch(increment());

Try The Demo

A more complete demo project is included. Run the following commands to clone this repository and start a calculator web application locally.

git clone https://github.com/xornot-io/redux-case.git
cd redux-case
yarn install
yarn start

Initial State Factory

You can alternatively provide a function which returns the initial state object. The function will be called lazily the first time the reducer is called, if the initial state is undefined. It may never be called if a defined initial state is provided when calling createStore.

const reducer = createReducer(() => {
  return {
    foo: "bar"
  };
});

Reducer Middleware

You can provide one or more reducer middleware functions after the initial state parameter. These middleware functions can modify or log the state before and after the action reducer receives it.

Middleware is only invoked when a case is matched. Not every time the reducer is invoked.

Middleware functions should have the following prototype.

<S>(state: S, next: (state: S) => S) => S;

Here's an example of using immer to replaced the state with a "draft" that can be safely modified in-place. We don't even have to return the modified state. Immer will see that the state was modified and handle creating a new state object with correctly enforced immutability.

import immer from "immer";
import { createReducer } from "@xornot/redux-case";
import { set, increment } from "./actions";

export const counter = createReducer({ count: 0 }, immer)
  .case(set, (state, action) => {
    state.count = action.value;
  });
  .case(increment, state => {
    state.count++;
  });

Lazy Action Definition

The reducer.case() method can be used at any time, even after the reducer has been used to create a store! This allows actions to be lazily defined.

Case For Multiple Actions

You can pass an array of actions to the .case() method to handle any of the actions with the same case callback. The action argument type will be a union of the action object types of the actions.

const foo = createAction("foo", () => {
  return { foo: "abc" };
});
const bar = createAction("bar", () => {
  return { bar: 42 };
});

const reducer = createReducer({}).case([foo, bar], (state, action) => {
  // The action parameter may be either or a foo or bar action object.

  if ("foo" in action) {
    // The action is foo, because foo action objects have a foo property.
    const foo = action.foo; // "abc"
  } else {
    // It's not a foo action, so it must be bar.
    const bar = action.bar; // 42
  }

  // ...
});

Inferring State and Action Types

Some TypeScript helper types are included which will allow you to infer the state type from a reducer or store, and the action type from an action creator.

import { InferState, InferAction } from "@xornot/redux-case";

type ReducerStateType = InferState<typeof reducer>;
type StoreStateType = InferState<typeof store>;
type ActionType = InferAction<typeof action>;

Classic Reducers

You can still have action type-safety using createAction, without using the createReducer utility. Actions created using the createAction utility have a .match(action: AnyAction): boolean method that works as a type guard.

import { createAction, InferAction } from "@xornot/redux-case";

const foo = createAction("foo", (num: number) => {
  return { value: num };
});

const bar = createAction("bar", (str: string) => {
  return { text: str };
});

const reducer = (state: MyState | undefined, action: AnyAction): MyState => {
  // Before matching the action, the action's property types are unknown.

  if (foo.match(action)) {
    // The action is now known to have foo action properties.
    const value: number = action.value;
  } else if (bar.match(action)) {
    // The action is now known to have bar action properties.
    const text: string = action.text;
  }

  // ...
};