React State Management with Recoil
Jakub Abramowski
Posted on July 1, 2021
Deciding which state management library to use in a new React project can be quite a challenge – there are so many options. Obviously Redux is high on the list, however it requires a lot of boilerplate even in small apps that don’t require a lot of global state management. On the other hand, React Context is very simple, but any change to the value prop causes all the consumer’s children to rerender, even when only a small bit of the data is being used by them. You could use Mobx, but there’s a steep learning curve there, especially if you’re not quite familiar with observables.
That said, there is nothing wrong with any of these solutions, but they all have one downside – they are ‘external’ to React. Wouldn’t it be better to have something created especially for React?
The new kid on the block comes from the React team themselves – it’s called Recoil. The library is still in the experimental phase and has not been released in a stable version, but it’s a good time to start playing around with it, since it might prove the best choice for React developers in the near future.
So what is Recoil and what makes it so special? I will try to explain while creating a small shop that allows users to purchase some products.
Let’s start with the basic stuff – first we need to wrap our app (or just the part that needs access to the global state) in RecoilRoot
(remember to import it from recoil, I won’t include all the imports here).
ReactDOM.render(
<React.StrictMode>
<RecoilRoot>
<App />
</RecoilRoot>
</React.StrictMode>,
document.getElementById("root")
);
That’s it, we’re all set to use Recoil in our code!
The smallest unit of state in Recoil is called an atom. It is a function that requires a unique key and a default value, and returns a writable piece of state.
const cartState = atom({
key: "cartState",
default: [],
});
To write/read from that atom, we need to use the useRecoilState hook.
const [cart, setCart] = useRecoilState(cartState);
Looks nice and simple, doesn’t it? And the best thing is, it has the exact same syntax as standard React hooks, which dramatically flattens the learning curve once you get familiar with some basic Recoil concepts.
Let’s stock our little shop with some products.
const products = [
{ name: "Toothbrush", price: 10 },
{ name: "Smart TV", price: 800 },
{ name: "Laptop", price: 600 },
{ name: "Chocolate", price: 12 },
{ name: "Apple juice", price: 5 },
];
Next, we’ll display them to the user so they can add them to their cart.
const ShopProducts = () => {
const setCart = useSetRecoilState(cartState);
const addToCart = (product) =>
setCart((cart) =>
cart.find((item) => item.name === product.name)
? cart
: [...cart, product]
);
return (
<>
PRODUCTS:
<div>
{products.map((product) => (
<p key={product.name}>
{product.name} (${product.price})
<button onClick={() => addToCart(product)}>Add</button>
</p>
))}
</div>
</>
);
};
In this component we do not need access to the cart, we only require a setter function to be able to add products to it. We can use the useSetRecoilState
hook in the setCart
function to achieve this.
Now all we need to do is render this component within our app.
const App = () => (
<div>
<ShopProducts />
</div>
);
Now we can see a list of products, each having a button next to it that says ‘Add to cart’. But what about the cart? We need a way of showing it’s content to the user. Let’s take care of that.
const Cart = () => {
const [cart, setCart] = useRecoilState(cartState);
const removeFromCart = (product) =>
setCart((cart) => cart.filter((item) => item.name !== product.name));
return (
<>
CART:
<div>
{!cart.length
? "Empty"
: cart.map((product) => (
<p key={product.name}>
{product.name} (${product.price})
<button onClick={() => removeFromCart(product)}>Remove</button>
</p>
))}
</div>
</>
);
};
And remember to include Cart in the app.
const App = () => (
<div>
<ShopProducts />
<Cart />
</div>
);
Now we are able to see the cart’s content or the string ‘Empty’ it’s empty. Of course – I know, I know – we could use some fancy icon to represent an empty cart, and we could also allow users to buy multiple products of the same type, but hey it’s just a simple app to demonstrate how easy it is to manage the state with Recoil, so bear with me please.
Next thing we need is to display the total price. We can do that using a selector.
const cartDetailsState = selector({
key: "cartDetailsState",
get: ({ get }) => {
const cart = get(cartState);
const total = cart.reduce((prev, cur) => prev + cur.price, 0);
return {
total,
};
},
});
Simple as that! Selectors let you create derived state from atoms, but you can also pass props to them, or even access setter functions, but we’ll get there later.
The next step is to add a component that will read the cart details state.
const CartDetails = () => {
const { total } = useRecoilValue(cartDetailsState);
return <p>TOTAL: ${total}</p>;
};
const App = () => (
<div>
<ShopProducts />
<Cart />
<CartDetails />
</div>
);
Item Discounts
Let’s say we want to allow the shop manager to set discounts for all the products. We can create a simple select with a few options, prepare an atom for holding the shop’s configuration, which will include the discount, and then expand the CartDetails
selector logic to include the new state:
const shopConfigState = atom({
key: "shopState",
default: {
discount: 0,
},
});
const cartDetailsState = selector({
key: "cartDetailsState",
get: ({ get }) => {
const { discount } = get(shopConfigState);
const cart = get(cartState);
const total = cart.reduce((prev, cur) => prev + cur.price, 0);
const discountAmount = total * discount;
return {
total,
discountAmount,
};
},
});
const CartDetails = ({ total, discount, discountAmount, setCart }) => (
<div>
<p>
DISCOUNT: {discount * 100}% (${discountAmount})
</p>
<p>TOTAL: {total}</p>
<p>TOTAL AFTER DISCOUNT: {total - discountAmount}</p>
<button onClick={() => setCart([])}>Clear cart</button>
</div>
);
const ShopConfig = () => {
const [shopState, setShopState] = useRecoilState(shopConfigState);
const updateDiscount = ({ target: { value } }) => {
setShopState({ ...shopState, discount: value });
};
return (
<div>
Discount:
<select value={shopState.discount} onChange={updateDiscount}>
<option value={0}>None</option>
<option value={0.05}>5%</option>
<option value={0.1}>10%</option>
<option value={0.15}>15%</option>
</select>
</div>
);
};
const App = () => (
<div>
<ShopConfig />
<ShopProducts />
<Cart />
<CartDetails />
</div>
);
Now our little shopping app is complete! The code looks very simple compared to Redux, but it has even more out-of-the-box features which make it a great competitor for all the other state management tools, and the coolest one is that it supports asynchronicity! Just like that, no additional libraries are needed, no extra code needs to be added.
Asynchronous
Let’s change the way we provide the product list to the shop.
const getProductsFromDB = async () =>
new Promise((resolve) => {
setTimeout(() => resolve(products), 2500);
});
const productsQuery = selector({
key: "Products",
get: async () => getProductsFromDB(),
});
getProductsFromDB
is a function that waits 2.5 second (to simulate a network latency, or database response time) to simply resolve a promise with the product list that we’ve created at the beginning. The important part here is the selector with an async getter function – it simply calls the aforementioned function.
Recoil is built to work with React Suspense, and this is how they can be connected.
const ShopProducts = () => {
const shopProducts = useRecoilValue(productsQuery);
const setCart = useSetRecoilState(cartState);
const addToCart = (product) =>
setCart((cart) =>
cart.find((item) => item.name === product.name)
? cart
: [...cart, product]
);
return (
<>
PRODUCTS:
<div>
{shopProducts.map((product) => (
<p key={product.name}>
{product.name} (${product.price})
<button onClick={() => addToCart(product)}>Add</button>
</p>
))}
</div>
</>
);
};
const App = () => (
<div>
<ShopConfig />
<React.Suspense fallback={<div>Loading products...</div>}>
<ShopProducts />
</React.Suspense>
<Cart />
<CartDetails />
</div>
);
From now on, before the product list gets displayed on the screen, the user will see a nice fallback loader component for 2.5 seconds.
What about handling errors? Simply wrap the component in an error boundary.
But what if I don’t really want to use React Suspense? It’s still experimental and using it in production is still discouraged (well, so is Recoil).
There is a way of using async Recoil without Suspense.
const ShopProducts = () => {
const setCart = useSetRecoilState(cartState);
const shopProducts = useRecoilValueLoadable(productsQuery);
const addToCart = (product) =>
setCart((cart) =>
cart.find((item) => item.name === product.name)
? cart
: [...cart, product]
);
switch (shopProducts.state) {
case "loading":
return <div>Loading products...</div>;
case "hasError":
throw shopProducts.contents;
case "hasValue":
return (
<>
PRODUCTS:
<div>
{shopProducts.contents.map((product) => (
<p key={product.name}>
{product.name} (${product.price})
<button onClick={() => addToCart(product)}>Add</button>
</p>
))}
</div>
</>
);
}
};
When using useRecoilValueLoadable
, we get access to an object containing the current state of an async selector’s promise and, when it’s resolved, either to contents or error. This works exactly the same as in case of using Suspense.
Summary
To sum up, there are numerous ways of dealing with global state in React apps. Some have a steep learning curve, others are inefficient and in most cases and dedicated to just some pieces of state that don’t change very often. Now that we have Recoil within our reach, we can write state logic in a very similar way to how we write UI logic. The result is that creating React apps is even simpler and even more fun!
If you’d like to experiment, the recoil-demo source is available on Github.
Happy coding!
Posted on July 1, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 28, 2024
November 27, 2024