# Primary API

This tutorial will provide you with an indepth insight into the "core" APIs of Easy Peasy. Usage of these APIs are considered enough to satisfy 90% of the state requirements* for React applications.

* don't quote me on that 😅

# Introducing the Model

Easy Peasy stores are based on model definitions.

Models are plain old javascript objects (POJOs) representing everything about your store - the state, the actions that can be performed on it, the encapsulated side effects, computed properties etc

They can be as wide (lots of properties) or deep (lots of nested objects) as you like.

We'll start off by demonstrating how to construct a model containing state and the actions used to update the state.

# State

Below is a simple model with a basic state structure containing a list of todos.

const model = {
  todos: [],
};

Given that models are just plain old javascript objects, you have a great deal of flexibility in how you structure or compose your models. The next example represents a structure that is closer to that of a real world use case.

const model = {
  products: {
    byId: {},
  },
  basket: {
    productsInBasket: [],
  },
  userSession: {
    isLoggedIn: false,
    user: null,
  },
};

As your application scales you can refactor your model to be composed of imports.

import productsModel from './products-model';
import basketModel from './basket-model';
import userSessionModel from './user-session-model';

const model = {
  products: productsModel,
  basket: basketModel,
  userSession: userSessionModel,
};

# Actions

In order to perform updates against your state you need to define an action against your model.

import { action } from 'easy-peasy';

const model = {
  todos: [],
  addTodo: action((state, payload) => {
    state.todos.push(payload);
  }),
};

There are few things to digest here.

# Arguments

Firstly, note how the action receives a state argument. This argument will contain the state that is local to the action, so the example above the value of the state argument would be:

{
  "todos": []
}

The second argument to actions, the payload, will be the value that was provided to the action when it was dispatched. If no value was provided to the action when it was dispatched, then the payload will be undefined.

# Modifying the state

The body of the action should perform the required updates to the state, utilizing the payload if it was provided to influence the update.

You perform these updates by mutating the state argument directly.

This might seem peculiar to you, especially if you are familiar with Redux which promotes the idea of returning new immutable versions of state (opens new window).

Don't worry, under the hood we convert the mutations into the equivalent immutable updates against the state via the amazing Immer (opens new window) library.

We can refactor the previous example to show the equivalent immutable operation that will be created.

import { action } from 'easy-peasy';

const model = {
  todos: [],
  addTodo: action((state, payload) => {
    return {
      ...state,
      todos: [...state.todos, payload],
    };
  }),
};

In our opinion a mutation based API provides a much better developer experience than having to manage immutability yourself.

That being said, if you prefer to return new immutable instances of your state, you can do so as shown above.

# Scoping Actions

You can attach actions at any level of your plain old javascript object model.

const model = {
  products: {
    byId: {},
  },
  basket: {
    productsInBasket: [],
    // 👇 Defining a "nested" action
    addProductToBasket: action((state, payload) => {
      state.productsInBasket.push(payload);
    }),
  },
  userSession: {
    isLoggedIn: false,
    user: null,
  },
};

Notice how the action is receiving the state that is local to it. i.e. the state argument contains the following value.

{
  "productsInBasket": []
}

# Bad Practices

There are few important points to make in the context of actions.

# 1. Don't destructure the state argument

action(({ todos }, payload) => {
  //       👆 destructuring the state argument is bad, m'kay
  todos.push(payload);
}),

Doing this will break our ability to convert your code into an immutable update and will result in your state not being updated.

If you are interested in why this happens; it is because Immer uses proxies (opens new window) to track which state is being mutated/updated. Destructuring breaks out of the proxy, thereby removing this ability and will result in your state not being updated as expected.

# 2. Don't execute any side effects within your action

Actions should remain synchronous and pure. They should only perform state updates and must not do things like making an API request.

action(({ todos }, payload) => {
  // 👇 side effects in actions are bad, m'kay
  fetch('/todos').then(response => response.json()).then(data => {
    state.todos = state.todos.concat(data);
  });
}),

If you need to perform side effects then you should encapsulate them within a Thunk, a concept we will introduce later in the tutorial.

# Creating a Store

Once you have your model defined you can create a store.

import { createStore } from 'easy-peasy';
import model from './model';

const store = createStore(model);

Easy peasy. 😎

The createStore function also accepts a second argument allowing you to pass configuration options to the store.

For example, if we were rehydrating a server side rendered application we could provide the server rendered state to our store via the initialState configuration option.

const store = createStore(model, {
  initialState: serverRenderedState,
});

# Some fun facts about the store

# 1. It's a Redux store

The store instance that is created is in fact just a Redux store (with a few enhancements added). Therefore you could use it with anything that expects a Redux store.

For example, the react-redux Provider:

import { Provider } from 'react-redux';
import store from './my-easy-peasy-store';

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root'),
);

# 2. It's a Redux store

It's worth stressing this point, as you can use all the APIs (opens new window) of a standard Redux store (opens new window).

store.subscribe(() => {
  console.log('A state changed occurred');
});

# 3. It's not just a Redux store

😅

Ok, so we have made a few enhancements to the API, extending the standard Redux store API (opens new window) with some Easy Peasy specific APIs. An example of one below.

import store from './my-easy-peasy-store';

store.getActions().addTodo('Learn Easy Peasy');

In the above we are using one of the extended APIs to get the actions defined in our model. We are then dispatching the addTodo action, providing it a payload of "Learn Easy Peasy".

You can read more about the extended API here.

# Connecting the Store

To utilize the store within your React application you need to wrap your application with the StoreProvider component, providing the store as prop.

import { StoreProvider } from 'easy-peasy';
import store from './my-easy-peasy-store';

ReactDOM.render(
  <StoreProvider store={store}>
    <App />
  </StoreProvider>,
  document.getElementById('root'),
);

This is a very similar experience when compared to using Redux.

# Using the Store

Easy Peasy ships with a variety of hooks allowing for convenient interaction with the store from your components.

# The useStoreState hook

To consume state within your components utilize the useStoreState hook.

import { useStoreState } from 'easy-peasy';

function Todos() {
  const todos = useStoreState((state) => state.todos);
  return <TodoList todos={todos} />;
}

The useStoreState hook accepts a selector function which should resolve the state that your component needs.

It's completely reasonable to use the useStoreState multiple times within a component to resolve all the various pieces of state you might require.

import { useStoreState } from 'easy-peasy';

function Todos() {
  const todos = useStoreState((state) => state.todos);
  return <TodoList todos={todos} />;
}

# Important note on selector optimization

The useStoreState will execute any time an update to your store's state occurs. It compares the newly resolved state against the previously resolved state via a strict equality check.

if (prevState !== nextState) {
  console.log('We will re-render your component');
} else {
  console.log('We will do nothing');
}

If the newly resolved state is not equal to the previously resolved state your component will be re-rendered, receiving the new state.

With this in mind, it is important to take care not to create a selector that will return a value which will always break strict equality checking.

// These are some examples of selectors that may have negative performance
// characteristics.

useStoreState((state) => {
  // We are creating a new object every time!
  return {
    name: state.name,
    age: state.age,
  };
});

useStoreState((state) => {
  // We are returning a new array every time!
  return [...state.fruits, ...state.vegetables];
});

In the above examples we are returning new object and array instances within our selector functions. These value will never be strictly equal to the previously resolved object/array values and therefore our component will re-render for any update to our store.

Please avoid pitfalls such as above. If you need to derive different forms of state, then we recommend either using computed properties (will be introduced later), or to pull out the individual pieces of state within your component and then derive the new state directly within your component's render.

# The useStoreActions hook

To use actions within our component we can utilize the useStoreActions hook.

import { useStoreActions } from 'easy-peasy';

function AddTodoForm() {
  // We provide a selector to resolve an action, rather than state
  //                                 👇
  const addTodo = useStoreActions((actions) => actions.addTodo);
  const [value, setValue] = React.useState('');
  return (
    <>
      <input onChange={(e) => setValue(e.target.value)} value={value} />
      {/* Dispatch the action with a payload
                                       👇    */}
      <button onClick={() => addTodo(value)}>Add Todo</button>
    </>
  );
}

Similar to the useStoreState hook we pass a selector function, however, now we are resolving an action instead of state.

We can dispatch our actions with or without a payload argument.

# Thunks

Thunks provide us with the capability to encapsulate side effects, whilst also having the ability to dispatch actions in order to update our state accordingly.

# Defining thunks

We can define a thunk against our model via the thunk API.

import { action, thunk } from 'easy-peasy';
//                 👆

const model = {
  todos: [],
  addTodo: action((state, payload) => {
    state.todos.push(payload);
  }),
  //         👇
  saveTodo: thunk(async (actions, payload) => {
    const { data } = await axios.post('/todos', payload);
    actions.addTodo(data);
  }),
};

Looking at our thunk you will see that instead of state it receives the actions that are local to the thunk. In this case the actions argument will have the following structure:

{
  "addTodo": Function
}

Within the example above our thunk is executing an API request, utilizing the payload that the thunk received as the POST data. We subsequently utilize the returned data, dispatching the addTodo action to update our store.

Notice how we are using async/await (opens new window) in this example to manage the asynchronous execution of our axios request. We can alternatively utilize a Promise (opens new window), however, we must take care to return the Promise (opens new window) so that Easy Peasy is able to manage the asynchronous execution of our thunk effectively. Below is our saveTodo thunk rewritten to illustrate this.

saveTodo: thunk((actions, payload) => {
  // Important to return the Promise
  // 👇
  return axios.post('/todos', payload)
    .then(({ data }) => {
      actions.addTodo(data);
    });
}),

# Dispatching thunks

# Some interesting information about thunks

Thunks have some interesting properties and recommended practices, which we will cover below.

# 1. You should handle errors within your thunk

Performing side effects come with risks. Networks could be down. Payloads invalid. etc.

We highly recommend that you develop an error handling strategy.

import { action, thunk } from 'easy-peasy';

const model = {
  error: null,
  todos: [],
  addTodo: action((state, payload) => {
    state.todos.push(payload);
  }),
  setError: action((state, payload) => {
    state.error = payload;
  }),
  saveTodo: thunk(async (actions, payload) => {
    try {
      const { data } = await axios.post('/todos', payload);
      actions.addTodo(data);
    } catch (err) {
      actions.setError(err.message);
    }
  }),
};

# 2. Thunks can also be synchronous

Whilst thunks tend to be asynchronous in nature, they need not be. It is completely valid to have a synchronous thunk too.

const model = {
  actionOne: action((state, payload) => {
    /* ... */
  }),
  actionTwo: action((state, payload) => {
    /* ... */
  }),
  thunkOne: thunk((actions, payload) => {
    if (condition) {
      actions.actionOne(payload);
    } else {
      actions.actionTwo(payload);
    }
  }),
};

# 3. Thunks can also dispatch other thunks

As your state needs scale and become more complex you may need the ability to coordinate/chain side effects. Thunks can be dispatched in exactly the same was as an action. Therefore they unlock this capability.

const model = {
  actionOne: action()
  thunkOne: thunk(async (actions, payload) => { /* ... */ }),
  thunkTwo: thunk(async (actions, payload) => {
    await actions.thunkOne(payload);
    actions.actionOne(payload);
  }),
};

Remember to await a dispatched thunk if they are asynchronous.

# 4. Thunks can access the store state

Sometimes you might need to read the store state in order to influence the logic within the thunk.

Thunks receive a 3rd argument which allow you to request the local state.

const model = {
  todos: [],
  saveAllTodos: thunk((actions, payload, helpers) => {
    const { todos } = helpers.getState();
    return Promise.all(todos.map((todo) => axios.post('/todos', todo)));
  }),
};

# 5. You can return data out of a thunk

Whatever is returned within a thunk will be returned to the dispatcher.

If we had the following thunk.

const model = {
  thunkOne: thunk((actions, payload) => {
    return `hello ${payload}`;
  }),
};

We could illustrate the result in the following example.

const thunkOne = useStoreActions((actions) => actions.thunkOne);

const thunkDispatchResult = thunkOne('world');

console.log(thunkDispatchResult);
// "hello world"

This is super useful when your thunk is asynchronous, as you can resolve the returned Promise, which would provide you with the guarantee that the execution has completed.

const asyncLoginThunk = useStoreActions((actions) => actions.asyncLoginThunk);

asyncLoginThunk({ username: 'ww', password: 'ww1984' }).then(() => {
  console.log('Login is complete');
  // Redirect to new page?
});

# Computed Properties

It is a common requirement within state management to need derived state. A basket component might need to derive the total price. A paging component may need the total number of items. There are many cases that can appear as your application evolves.

To avoid scattering your application with these computations Easy Peasy provides the computed API, a powerful utility that allows you to quickly and succinctly define derived state computations directly against your model.

# Defining a computed property

To create a computed property is a matter of extending your model, utilising the computed helper to define the derived state logic for the property.

import { computed } from 'easy-peasy';
//         👆

const model = {
  todos: [],
  //            👇
  todoCount: computed((state) => state.todos.length),
};

# Further insights into computed properties

We'd like to stress the following information in regards to computed properties.

# 1. They are hyper optimized

Easy Peasy will do all of the required optimization under the hood to keep your computed properties as performant as possible.

The derived state of computed properties are cached, with the cache automatically being busted if the state that the derived state depends upon has changed. Even if the state that the computed properties depend on has changed we won't immediate calculate the new derived state. Computed properties have a lazy resolution behaviour and will only be calculated if there is a component currently using the derived data.

# 2. You can make them accept runtime arguments

If you need to provide a runtime argument in order to derive the correct state then you can adopt the following strategy.

const model = {
  products: [],
  getById: computed((state) => {
    // Instead of returning derive state we will return a function that
    // accepts an argument. The function will then return the actual derived
    // state when executed
    return (id) => state.products.find((product) => product.id === id);
  }),
};

Whilst this can be helpful, we lose some of our performance characteristics as Easy Peasy will only cache the returned function, not the products that the function will resolve. To help address this you can use a memoization library of your choice to memoize the internal function.

import memoizerific from 'memoizerific';

const model = {
  products: [],
  getById: computed((state) => {
    // Wrap the returned function with your memoize library of choice
    //      👇
    return memoizerific(50)(
      (id) => state.products.find((product) => product.id === id),
      1000, // 👈 declare the size of the cache
    );
  }),
};

# 3. Only use them to derive state, not execute side effects

Computed properties should only return derived state, and do nothing else.

computed(state => {
  // 👇 side effects in computed properites are bad, m'kay
  return fetch('/todos').then(response => response.json());
}),