Using computed properties
In the previous section of our application we added the capability to execute side effects via thunks.
In this section we are going to look at how we can take advantage of the computed API in order to support derived data. This will help us clean up the more complicated state mapping that is occurring within some of our useStoreState instances.
Introducing the computed API
The computed API allows you to define a piece of state that is derived from other state within our store.
import { computed } from 'easy-peasy'; // 👈 import the helper
const sessionModel = {
user: { username: 'jane' },
// 👇 define a computed property
isLoggedIn: computed(state => state.user != null)
}
You can access computed state just like any other state via the useStoreState hook.
Apart from helping you to avoid repeating logic that derives state within your application, they also have really nice performance characteristics. For instance, they are only computed on-demand (i.e. only if they are currently being accessed by a mounted component).
In addition to this computed properties will only be recalculated if their input state changes. This means that you can resolve any data type from a computed property (e.g. a new array/object instance) and they won't fall into the same performance pitfalls that can be experienced when deriving state within a useStoreState hook.
Refactoring the application to use computed properties
Computed properties are the perfect candidate to help us clean up the more advanced state mapping that is happening within some of our application's components. Let's refactor each derived data case.
Basket count
First up, let's add a computed property to represent the total count of products within our basket.
// src/model/basket-model.js
import { action, computed, thunk } from 'easy-peasy';
// 👆 import the helper
// ...
const basketModel = {
// 👇 define the computed property
count: computed(state => state.productIds.length),
// ...
We can then update the BasketCount
component to instead use this computed property.
export default function BasketCount() {
- const basketCount = useStoreState(state => state.basket.productIds.length);
+ const basketCount = useStoreState(state => state.basket.count);
return (
Products in basket
Next up, we will add a computed property to represent the products that are currently in our basket. This is a more advanced implementation as we will use data from both our our basket model and our product model.
Computed properties optionally allow you to provide an array of state resolver functions as the first argument to the computed property definition. These state resolver functions will receive the state that is local to the computed property, as well as the entire store state, and allow you to resolve specific slices of state that your computed function will take as an input.
Apart from granting you access to the entire store state, using resolver functions enables performance optimisations as they reduce the likelihood of your computed property needing to be recalculated (i.e. they are only recalculated when their input state changes).
Let's go ahead and define a computed property, utilising state resolvers, which will allow us to represent the products currently in our basket.
// src/model/basket-model.js
// ...
const basketModel = {
productIds: [2],
products: computed(
// 👇 These are our state resolvers, ...
[
state => state.productIds,
(state, storeState) => storeState.products.items
],
// the results of our state resolvers become the input args
// 👇 👇
(productIds, products) => productIds.map(productId =>
products.find(product => product.id === productId)
),
),
// ...
We can now update our Basket
component to use our computed property.
// src/components/basket.js
import { useStoreActions, useStoreState } from "easy-peasy";
export default function Basket() {
const removeProductFromBasket = useStoreActions(
actions => actions.basket.removeProduct,
);
- const basketProducts = useStoreState(state =>
- state.basket.productIds.map(productId =>
- state.products.items.find(product => product.id === productId),
- ),
- );
+ const basketProducts = useStoreState(state => state.basket.products);
return (
This is far cleaner, and should we need to access the products that are in our basket anywhere else in our application we have a much simpler mechanism by which do so, without the need to do any complicated state mapping.
Getting a product by id
The mapState
function of our Product
's useStoreState hook utilises an incoming id
property to derive the product to render.
We can create a computed property that supports runtime arguments (such as component props) by returning a function within the computed property definition.
Let's add a getById
computed property to our product model.
// src/model/products-model.js
import { computed } from "easy-peasy";
const productsModel = {
items: [
{ id: 1, name: "Broccoli", price: 2.5 },
{ id: 2, name: "Carrots", price: 4 }
],
getById: computed(state =>
// 👇 return a function that accepts an "id" argument
id => state.items.find(product => product.id === id)
),
// ...
We can then refactor the Product
component to use this computed property.
// src/components/product.js
import React, { useCallback, useState } from 'react';
import { useStoreActions, useStoreState } from 'easy-peasy';
export default function Product({ id }) {
const addProductToBasket = useStoreActions(
actions => actions.basket.addProduct,
);
- const product = useStoreState(state =>
- state.products.items.find(product => product.id === id),
- );
+ const product = useStoreState(state => state.products.getById(id));
// state to track when we are saving to basket
const [adding, setAdding] = useState(false);
// ...
Note how we are executing the getById
computed property function, providing the id
prop to it.
useStoreState(state => state.products.getById(id));
Review
We have now covered computed, a very powerful mechanism that allows us to easily derive data and unlock some really awesome performance characteristics.
In the next section we will review the final piece of our API - action listeners.
You can view the progress of our application refactor here.
Bonus Points
You can add internal memoisation to the function that you return within your computed property by leveraging the memo API.
// src/model/products-model.js
import { computed, memo } from "easy-peasy";
// 👆
const productsModel = {
items: [
{ id: 1, name: "Broccoli", price: 2.5 },
{ id: 2, name: "Carrots", price: 4 }
],
getById: computed(state =>
memo(id => state.items.find(product => product.id === id), 100)
// cache size 👆
),
// ...
I wouldn't suggest doing this unless you anticipated the function to be called multiple times with varying arguments and the function is also doing complex/expensive deriving.