DEV Community

Cover image for Pragmatic types: Redux as Finite State Machine
stereobooster
stereobooster

Posted on • Edited on

Pragmatic types: Redux as Finite State Machine

This post assumes some knowledge about Redux and types, but feel free to ask.

This is the third post in series. Code for this post is here

  1. Redux as Finite State Machine
  2. Side effects in Redux
  3. Optimistic UI
  4. I created a monster

What we want to do

We want to build a form where the user can enter data, as soon as the user submits the form we need to show a loading state while AJAX request is running, as soon as AJAX request finishes show results in case of success or error in case of failure of the AJAX request.

Let's create a "classical" reducer for this task and "Finite State Machine" reducer so we can compare. Full code is in this repository.

"Classical" reducer

This is how "classical" reducer can look like:

export default (reduxState: State = defaultState, action: Actions): State => {
  switch (action.type) {
    case "SUBMIT_FRUIT":
      return {
        ...reduxState,
        state: "fruit_loading",
        form: action.form
      };
    case "SUBMIT_FRUIT_ERROR":
      return {
        ...reduxState,
        state: "fruit_error",
        error: action.error
      };
    case "SUBMIT_FRUIT_OK":
      return {
        ...reduxState,
        state: "fruit_ok",
        resonse: action.resonse
      };
    default:
      exhaustiveCheck(action.type);
      return reduxState;
  }
};
Enter fullscreen mode Exit fullscreen mode

SUBMIT_FRUIT is an action dispatched in response to the form submit.
SUBMIT_FRUIT_ERROR and SUBMIT_FRUIT_OK are dispatched in response to side effect e.g. AJAX request. We can use different solutions for side effects, like redux-thunk, redux-saga, redux-observable or redux-loop. Let's not focus on this, instead, we will trigger side effect explicitly with dispatch.

Here is how AJAX request can look like:

export const fruitSubmitSideEffect = (dispatch: Dispatch, form: FruitForm) => {
  // uses fetch inside returns a Promise
  fruitRequest(form).then(
    resonse => {
      dispatch({
        type: "SUBMIT_FRUIT_OK",
        resonse
      });
    },
    error => {
      dispatch({
        type: "SUBMIT_FRUIT_ERROR",
        error
      });
    }
  );
};

// and later

export default connect(
  () => ({}),
  (dispatch: Dispatch) => ({
    submit: (form: FruitForm) => {
      dispatch({ type: "SUBMIT_FRUIT", form });
      fruitSubmitSideEffect(dispatch, form);
    }
  })
)(Component);
Enter fullscreen mode Exit fullscreen mode

Previous state in action used for the creation of the new state, but it is not explicitly checked:

return {
  ...reduxState,
  ...newPartsOfState
};
Enter fullscreen mode Exit fullscreen mode

Type of State can look like this:

export type State = {
  state: "initial" | "fruit_loading" | "fruit_error" | "fruit_ok";
  form?: FruitForm;
  error?: mixed;
  resonse?: FruitResponse;
};
Enter fullscreen mode Exit fullscreen mode

One of the consequences is that we will need write additional type checks:

export default ({ state }: { state: State }) => {
  switch (state.state) {
    case "fruit_ok":
      return (
        state.resonse && // additional type check, that it is not undefined
        state.resonse.map(item => {}))
  }
Enter fullscreen mode Exit fullscreen mode

Finite State Machine

Finite State Machine (FSM) suppose to have finite states. Let's force it with the type system. This is Flow type, but TypeScript would look similar (there is no need in {||} in TS).

export type State =
  | {|
      state: "initial"
    |}
  | {|
      state: "fruit_loading",
      form: FruitForm
    |}
  | {|
      state: "fruit_error",
      form: FruitForm,
      error: mixed
    |}
  | {|
      state: "fruit_ok",
      form: FruitForm,
      resonse: FruitResponse
    |};
Enter fullscreen mode Exit fullscreen mode

Now we can't use the previous state without checking it. If we would do

return {
  ...reduxState,
  state: "fruit_loading",
  form: action.form
};
Enter fullscreen mode Exit fullscreen mode

Flow would complain:

Could not decide which case to select. Since case 2 [1] may work but if it doesn't case 3 [2] looks promising too. To fix add a type annotation to .form [3] or to .state [3].

     src/redux-fsm/state.js
 [1] 12│   | {|
     13│       state: "fruit_loading",
     14│       form: FruitForm
     15│     |}
 [2] 16│   | {|
     17│       state: "fruit_error",
     18│       form: FruitForm,
     19│       error: mixed
     20│     |}
Enter fullscreen mode Exit fullscreen mode

So now we need to do something like this:

switch (action.type) {
  case "SUBMIT_FRUIT":
    switch (reduxState.state) {
      case "initial":
        return {
          state: "fruit_loading",
          form: action.form
        };
      default:
        throw new Error("Inavlid transition");
    }
}
Enter fullscreen mode Exit fullscreen mode

We check what action is about to happen, what is the previous state and after this, we decide What to do. This approach forces us to think about all transition in the system explicitly.

initial
  SUBMIT_FRUIT       -> fruit_loading (1)
  SUBMIT_FRUIT_ERROR -> ?             (2)
  SUBMIT_FRUIT_OK    -> ?             (2)
fruit_loading
  SUBMIT_FRUIT       -> fruit_loading (3)
  SUBMIT_FRUIT_ERROR -> fruit_error   (4)
  SUBMIT_FRUIT_OK    -> fruit_ok      (5)
fruit_error
  SUBMIT_FRUIT       -> fruit_loading (6)
  SUBMIT_FRUIT_ERROR -> ?             (7)
  SUBMIT_FRUIT_OK    -> ?             (7)
fruit_ok
  SUBMIT_FRUIT       -> fruit_loading (6)
  SUBMIT_FRUIT_ERROR -> ?             (7)
  SUBMIT_FRUIT_OK    -> ?             (7)
Enter fullscreen mode Exit fullscreen mode

Side note: Why would you want to do this? To formally specify UIs, to prove that there are no errors in UI logic. For example:

Side note 2: I implemented "reversed" FSM in the reducer, it checks action first and the state second

(1, 5) "Happy" path - user submits the form and gets a response.
(1, 4) Error path - user submits the form and gets an error.
(6) Repeated search - there is already error or successful response, the user repeats the search.
(2) Never happens - we can assume it never happens and throws an exception in that case.
(7) Race condition - we already have a response (or error) and a new one arrives, can happen only if we allow more than one side effect at a time.
(3) Repeated search - there is one search pending and the user asks for different or maybe clicks impatiently. That's an interesting case. What shall we do? We can:

  • ignore it (also it would make sense to visually communicate it via the disabled button)
  • cancel the previous request and launch a new one
  • launch a new one and forget about previous. This is basically what we did in "classical" approach, but this is also will lead to a situation (7) which is a race condition. Also, this approach introduces race condition in (1, 5) and (1, 4) scenarios.

For this post, I selected to ignore it, as the simplest solution, maybe I will implement cancel in the in the next post.

This is why you want to use FSM, this approach helps to find "holes" in logic. And the more states there are in the system, the more potential holes are hidden in there.

If you think this is too much trouble to find those types of bugs, think of the typical IT support question: "Have you tried to turning it off and on again?". Yep, there is somewhere state related bug hidden and the way out is to restart the system to reset the state to initial state.

Guy from IT crowd saying

On the other side, I would agree JS (or Flow or TS) syntax is a bit clumsy for this kind of task. Pattern matching with the switch is not expressive. Redux requires even more boilerplate than traditionally. Tell me what do you think. Would you use it if it would require less boilerplate?

Photo by Dan Lohmar on Unsplash


This post is part of the series. Follow me on twitter and github.

Top comments (8)

Collapse
 
qm3ster profile image
Mihail Malo

How do you feel about using object literals instead of match?

Collapse
 
stereobooster profile image
stereobooster

I'm ok with it (in general), but I not sure how to do exhaustive check with object literals. From machine code point of view, this is a different code, but this doesn't matter unless this is "critical path" code.

Collapse
 
qm3ster profile image
Mihail Malo

I've found that it's universally faster.
In TypeScript exhaustive checks work, not sure about flow.
The default case is when indexing into the object returns undefined.

I like it for performance reasons and also because it seems more functional to me, containing values (which can also be functions when needed) and not expressions.

Thread Thread
 
stereobooster profile image
stereobooster

In TypeScript exhaustive checks work, not sure about flow.

Do you have a code snippet?

I've found that it's universally faster.

Based on benchmark or personal impression?

Thread Thread
 
qm3ster profile image
Mihail Malo

Turns out there's some trickery at work, but I never have to think about it because it's in another file in my actual projects.

type FullEvent<EP, T extends keyof EP> = { type: T } & EP[T]

type FullEvents<EP> = { [T in keyof EP]: FullEvent<EP, T> }

type AnyEvent<EP> = FullEvents<EP>[keyof EP]

// Your code begins here

interface EventPayloads {
  increment: { increment: number }
  reset: {}
}

type Event = AnyEvent<EventPayloads>

type State = number

export const matchReducer = (state: State, event: Event): State => {
  switch (event.type) {
    case "increment":
      return state + event.increment
    case "reset":
      return 0
  }
  // Uncommenting this disables exhaustiveness checking
  // throw new Error("Unsupported Event")
}

type Reducer<S, EP, T extends keyof EP> = (
  state: S,
  event: FullEvent<EP, T>,
) => S

const reducersLookup: {
  [T in Event["type"]]: Reducer<State, EventPayloads, T>
} = {
  increment: (s, e) => s + e.increment,
  reset: () => 0,
}

export const lookupReducer = (state: State, event: Event): State => {
  const reducer = reducersLookup[event.type] as Reducer<
    State,
    EventPayloads,
    typeof event.type
  >
  if (!reducer) {
    // this is fine, because our exhaustiveness check already happened when constructing the lookup
    throw new Error("Unsupported Event")
  }
  return reducer(state, event)
}

Also, in my case all variadic properties are under event.data, not root of event, so event always looks like {type: T, data: EventPayloads[T]}, but I wanted to implement your case.

Thread Thread
 
stereobooster profile image
stereobooster

It appears it is pretty easy in TS

type ActionTypes = 'increment' | 'reset'
const reducers: { [key in ActionTypes]: () => void } = {
    increment: () => {},
    reset: () => {}
}
Thread Thread
 
stereobooster profile image
stereobooster

And with Flow too

type ActionTypes = 'increment' | 'reset' 
const reducers = {
    increment: () => {},
    reset: () => {}
}
const reducer = (a: ActionTypes) => {
  const r = reducers[a];
}
Thread Thread
 
qm3ster profile image
Mihail Malo

Making them take the correct Action object is the more hacky part.
But I guess it makes sense, switch takes place inside function body, which does more inference, while the lookup is statically declared functions, which always requires more annotation.