# effectOn

Allows you to declare an effect within your model which will execute every time the targeted state changes. Think of it as a thunk that automatically fires when specific state changes.

*Note: this is an experimental API. We are pre-releasing it to allow for early feedback. The API is subject to breaking changes with any release of Easy Peasy. As such we have prefixed the API with "unstable*", much like React does with its experimental APIs. Once the API has stabilised the "unstable*" prefix will be removed and semver based releases will be respected.*

# API

The unstable_effectOn function is described below.

# Arguments

  • stateResolvers (Array<Function>)

    State resolvers allows you to isolate the specific parts of your state that your effect will track. Each state resolver function receives the following arguments:

    • state (Object)

      The local state against which your effectOn property is bound.

    • storeState (Object)

      The entire store state

  • handler (Function, required)

    The handler that will execute the desired effect. It can be asynchronous, and receives the following arguments:

    • actions (Object)

      The actions that are local to the thunk. This allows you to dispatch an action to update state should you require.

    • change (Object)

      Represents the change that took place, causing the effect to run. It contains the following properties:

      • prev (Array<any>)

        The previous state that was mapped by your stateResolvers.

      • current(Array<any>)

        The current state that was mapped by your stateResolvers after the change in state occurred.

      • action (Object)

        An object representing information about the action that caused the change. It contains the following properties:

        • type (string)

          The action identifer.

        • payload (any)

          The payload, if there was one, that was attached to the action.

    • helpers (Object)

      Helpers which may be useful for more advanced effect implementations. The object contains the following properties:

      • dispatch (Function)

        The Redux dispatch function, allowing you to dispatch "standard" Redux actions.

      • getState (Function)

        When executed it will provide the state that is local to the thunk.

        Note: whilst you are able to access the store's state via this API your effect should not perform any mutation of this state. That would be considered an anti-pattern. All state updates must be contained within actions. This API exists within a thunk purely for convenience sake - allowing you to perform logic based on the existing state.

      • getStoreActions (Function)

        When executed it will get the actions across your entire store.

        We don't recommend dispatching actions like this, and invite you to consider creating an actionOn or thunkOn listener instead.

      • getStoreState (Function)

        When executed it will provide the entire state of your store.

        Note: whilst you are able to access the store's state via this API your thunk should not perform any mutation of this state. That would be considered an anti pattern. All state updates must be contained within actions. This API exists within a thunk purely for convenience sake - allowing you to perform logic based on the existing state.

      • injections (Any, default=undefined)

        Any dependencies that were provided to the createStore configuration will be exposed via this argument. See the StoreConfig documentation on how to provide them to your store.

      • meta (Object)

        This object contains meta information related to the thunk. Specifically it contains the following properties:

        • parent (Array)

          An array representing the path of the parent against which the thunk was attached within your model.

        • path (Array)

          An array representing the full path to the thunk based on where it was attached within your model.

    The handler allows you to return a dispose function. The dispose function can be asynchronous or synchronous and will be executed prior to the next execution. Please read the docs on this feature.

# Tutorial

# Introduction

Two arguments are provided to unstable_effectOn; namely the stateResolvers and the handler. The stateResolvers are an array of functions which should resolve the target state that should be tracked. When the tracked state changes the handler function is executed..

The handler can be asynchronous or synchronous. It receives the store actions, a change object containing the prev values for the targeted state and the current values as well as the action that caused the change in state. It additionally receives a helper argument allowing you to access the store state etc.

The handler additionally allows you to return a dispose function, which will be executed prior to the next execution of the handler. This can be useful in performing things like API call cancellation etc.

import { unstable_effectOn } from 'easy-peasy';

const todosModel = {
  items: [],
  saving: false,
  setSaving: action((state, payload) => {
    state.saving = payload;
  }),
  unstable_effectOn(
    // Provide an array of "stateResolvers" to resolve the targeted state:
    [state => state.items],
    // Provide a handler which will execute every time the targeted state changes:
    async (actions, change) => {
      const [items] = change.current;
      actions.setSaving(true);
      await todosService.save(items);
      actions.setSaving(false);
    }
  )
};

# Execution behaviour

In regards to the execution of your effect; should the targeted state change multiple times your effect will be executed for each state change. This means that it is possible to have multiple executions of your effect running concurrently, especially in the case of long running asynchronous effects.

Each effect execution will not wait for any prior executions to complete. Should you desire something like this you could look to implement a debounce (opens new window) within your effect handler.

Alternatively, you could make use of the dispose in order to clean up effect executions should a new execution begin. Please read the docs on this feature.

# How state changes are determined

Strict equality checking is used (i.e. prevState === currentState) when determining if the targeted state has changed.

It is important you resolve the actual state from your store, and don't create additional wrapper objects/arrays within the state resolvers. If you did so it would break the strict equality checking, causing your effects to run for every change to the store.

This anti-pattern can be illustrated below:

const myModel = {
  one: 'one',
  two: 'two',
  effectOn(
    [
      (state) => {
        // This is an anti-pattern. You are returning a new object instance
        // for every execution. This will result in the effect running for
        // _every_ state change.
        //    👇
        return {
          one: state.one,
          two: state.two
        };
      }
    ],
    (actions, change) => {
      console.log('I run for every change to the store!');
    }
  )
};

Instead you should resolve each piece of state individually:

const myModel = {
  one: 'one',
  two: 'two',
  unstable_effectOn(
    [
      // 👍
      (state) => state.one,
      (state) => state.two
    ],
    (actions, change) => {
      console.log('I only run when "one" or "two" have changed');
    }
  )
};

# Utilising a dispose function

An effect can return a dispose function, which will be executed should a newer execution of the effect occur due to another state change and the previous effect execution has not yet completed. This provides a mechanism by which you can clean up resource hooks or cancel API calls.

import { unstable_effectOn } from 'easy-peasy';

const todosModel = {
  items: [],
  unstable_effectOn(
    [state => state.items],
    (actions, change) => {
      const [items] = change.current;
      const request = todosService.save(items);
      // Return a dispose function to abort the current save call prior to
      // executing the handler again.
      //     👇
      return () => request.abort();
    }
  )
};

The dispose function must always be return synchronously from your effect, whilst the effect can still execution asynchronous code internally. This restriction allows us to execute your dispose function as early as possible should a new effect execution begin. The example below illustrates the anti-pattern of resolving the dispose asynchronously.

unstable_effectOn(
  [(state) => state.items],
  // We are using async/await, therefore our dispose is now resolved
  // asynchronously via the implicitly returned Promise.
  // 👇
  async (actions, change) => {
    const [items] = change.current;
    const request = await todosService.save(items);
    return () => request.abort();
  },
);

Also note in the above example that as we are awaiting on the todosService.save call it will always be resolved prior to the dispose being returned.

# Limitations

  • Effects cannot be dispatched directly - e.g. the useStoreActions hook will not include effects.
  • Effect execution timing is not guaranteed. It's best to consider them as asynchronous operations where you are unable to attach external logic against their resolution.