React State Management with Recoil

jakubabramowski

Jakub Abramowski

Posted on July 1, 2021

React State Management with Recoil

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")
);
Enter fullscreen mode Exit fullscreen mode

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: [],
});
Enter fullscreen mode Exit fullscreen mode

To write/read from that atom, we need to use the useRecoilState hook.

const [cart, setCart] = useRecoilState(cartState);
Enter fullscreen mode Exit fullscreen mode

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 },
];
Enter fullscreen mode Exit fullscreen mode

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>
   </>
 );
};
Enter fullscreen mode Exit fullscreen mode

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>
 );
Enter fullscreen mode Exit fullscreen mode

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>
   </>
 );
};
Enter fullscreen mode Exit fullscreen mode

And remember to include Cart in the app.


const App = () => (
   <div>
     <ShopProducts />
     <Cart />
   </div>
 );

Enter fullscreen mode Exit fullscreen mode

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,
   };
 },
});
Enter fullscreen mode Exit fullscreen mode

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>
 );

Enter fullscreen mode Exit fullscreen mode

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>
 );

Enter fullscreen mode Exit fullscreen mode

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(),
});

Enter fullscreen mode Exit fullscreen mode

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>
 );

Enter fullscreen mode Exit fullscreen mode

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>
       </>
     );
 }
};

Enter fullscreen mode Exit fullscreen mode

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!

💖 💪 🙅 🚩
jakubabramowski
Jakub Abramowski

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