Management Effect

The actions in the model must be pure functions and cannot have any side effects during execution. However, in real-world scenarios, we often encounter many side effects, such as: requesting data from an HTTP API to obtain state data, or modifying localStorage or sending events while updating the state. In Reduck, side effects are managed through the model's effects function.

Modifying State via Side Effects

The most common scenario in which side effects modify the state is requesting data from an HTTP API to update state data.

Let's take a simple todoModel as an example. It has a side effect function load that requests the TODO list from a remote server. After the request succeeds, it updates the state.items field.

const todoModel = model('todo').define({
  state: {
    items: [],
    loading: false,
    error: null,
  },
  actions: {
    load: {
      pending(state) {
        state.loading = true;
      },
      fulfilled(state, items) {
        state.items = items;
        state.loading = false;
      },
      rejected(state, error) {
        state.error = error;
        state.loading = false;
      },
    },
  },
  effects: {
    // Promise effects
    async load() {
      return new Promise(resolve => {
        setTimeout(() => resolve(['Learn Modern.js']), 2000);
      });
    },
  },
});

The side effect function is uniformly defined under the effects field. Here we have written a load function that returns a Promise. After the Promise is successfully executed, it returns the TODO list ["Lerna Modern.js"].

Side effect functions need to work with actions to modify state. Therefore, we define a load object in actions (the name needs to be consistent with the name of the side effect function under effects), which includes three actions (pending, fulfilled, rejected) that handle the three states (pending, fulfilled, rejected) of the Promise returned by the side effect function load:

  • pending: Receives the current state state as a parameter and sets the loading flag to true in the new state.
  • fulfilled: Receives the current state state and the Promise fulfilled value items as parameters, and sets items to the parameter items and loading to false in the new state.
  • rejected: Receives the current state state and the Promise rejected error error as parameters, and sets error to the parameter error and loading to false in the new state.

How do we call the effects function in the component? The effects function will be merged into the actions object, so you can call the effects function through the actions object, as shown below:

function Todo() {
  const [state, actions] = useModel(todoModel);

  useEffect(() => {
    // invoke effects function
    actions.load();
  }, []);

  if (state.loading) {
    return <div>loading....</div>;
  }

  return (
    <div>
      <div>
        {state.items.map((item, index) => (
          <div key={index}>{item}</div>
        ))}
      </div>
    </div>
  );
}

In the example above, the three actions pending, fulfilled, and rejected are generally required for HTTP requests used to obtain data. Reduck provides a utility function handleEffect to simplify the creation of actions in this scenario.

For this type of side effect scenario, handleEffect stipulates that the state structure of the model contains three fields: result, error, and pending, with initial values of:

{
  result: null,
  error: null,
  pending: false,
}

Calling handleEffect will return the following data structure:

{
  pending() { // ... },
  fulfilled() { // ... },
  rejected() { // ... }
}

This data structure is the same as the data structure of the load object under actions. The object returned by handleEffect actually corresponds to the three actions required by the Effects function.

We can use handleEffect to rewrite todoModel:

const todoModel = model('todo').define({
  state: {
    items: [],
    loading: false,
    error: null,
  },
  actions: {
    load: handleEffect({ result: 'items' }),
  },
  effects: {
    // Promise effects
    async load() {
      return new Promise(resolve => {
        setTimeout(() => resolve(['Learn Modern.js']), 2000);
      });
    },
  },
});

The handleEffect function sets result to items in the received parameter object. Because todoModel uses items as the key to save the TODO list data instead of using the default result as the key of handleEffect, configuration is required here.

It is obvious that the todoModel implemented through handleEffect is much more concise than the previous implementation.

If you do not want all three states (pending, fulfilled, rejected) to be automatically handled by handleEffect, for example, if the fulfilled state requires more complex data processing and needs to be manually handled, but you still want to automate the handling of the pending and rejected states, you can use the following method:

actions: {
    load: {
      ...handleEffect(),
      fulfilled(state, payload) {
        // manual handle
      },
    },
  },

handleEffect API.

In the Effects function, you can also manually call Actions. For example:

const todoModel = model('todo').define((context, utils) => ({
  state: {
    items: [],
    loading: false,
    error: null,
  },
  actions: {
    pending(state) {
      state.loading = true;
    },
    fulfilled(state, items) {
      state.items = items;
      state.loading = false;
    },
  },
  effects: {
    async load() {
      // use utils.use get cuttent Model actions
      const [, actions] = utils.use(todoModel);
      // invoke action
      actions.pending();

      return new Promise(resolve => {
        setTimeout(() => {
          const items = ['Learn Modern.js'];
          // invoke action
          actions.fulfilled(items);
          resolve(items);
        }, 2000);
      });
    },
  },
}));

You can use the use function to load other Models (including the Model itself) to achieve Model communication.

Side effects do not affect state

In some cases, we only need to read State and execute relevant side effect logic, and the side effect will not modify State.

For example, store some State in localStorage:

const fooModel = model('foo').define((context, utils) => ({
  state: {
    value: 'foo',
  },
  effects: {
    setLocalStorage(key) {
      const [state] = utils.use(fooModel);
      localStorage.set(key, state.value);
      return 'success';
    },
  },
}));

or send message to server:

const fooModel = model('foo').define({
  state: {
    value: 'foo',
  },
  effects: {
    async sendData(data) {
      const res = await fetch('url', {
        method: 'POST',
        body: data,
      });
      return res.json();
    },
  },
});

Sometimes, we want to execute subsequent logic directly based on the execution result of the side effect function. In this case, we need to use the return value of the Effects function.

For example, when the send button is clicked and the data is successfully sent, close the current popup immediately; if it fails, display an error message. We can achieve this through the following code:

// The code is for illustration only and cannot be executed.
// Response function of the "send" button within the component.
const handleClick = async () => {
  // sendData returns a string that represents the state.
  const result = await actions.sendData('some data');
  if (result === 'success') {
    // Close the popup window.
    closeModal();
  } else {
    // show error
    showError(result);
  }
};
INFO