DEV Community

Yoni Weisbrod
Yoni Weisbrod

Posted on

When to use Writeable Selectors in RecoilJS

A while back, I was leafing through the RecoilJS documentation trying to get a feel for the library, and I came upon a section titled:

Writeable Selectors

"Weird," I thought. Aren't selectors there to read data that can be computed from state?

Why would I ever want to use a selector to update state?

Turns out the selector is a pretty great tool for mutating state! This is for two main reasons: It lives outside your view component, which gives us the benefit of seprating our model and view domains, and it can do everything else that a selector can - i.e., it can directly access whatever state objects you need for the desired mutation.

Back to Basics - Managing State with RecoilJS

Let's take a step back and look at the basics of how state is managed with RecoilJS.

To get started, I recommend creating a sample app, installing RecoilJS, and then wrapping your app in RecoilRoot - all of this is covered in the RecoilJS Getting Started guide.


Less familiar with Recoil selectors? Check out my quick refresher on Egghead


First, we have atoms - these are our basic state objects.

If I were to write a simple restaurant point of sale application, I might express one table's order using an order atom:

const order = atom({
  key: 'order',
  default: ['garlic bread'],   // on the house!
});

Then, in my React component, I would consume the atom using the appropriate RecoilJS action hook™️ for consuming data:

import { useRecoilValue } from 'recoil';

export default function OrderDisplay() {
    const myOrder = useRecoilValue(order);

    return (
        <div>
            <h3>Current Order:</h3>
            {myOrder.map((food, i) => {
                return <div key={i}>{food}</div>
            })}
        </div>
    )
}

Notice that I can't consume atoms directly - I need to use one of the RecoilJS hooks, such as useRecoilValue. Choosing the correct hook gives me finer-grained control over how I subscribe to the state object, in terms of its effect on re-rendering.

In this case, because I'm only listening to data and not writing it, I'm better off using useRecoilValue than either useSetRecoilState or the generic useRecoilState .

Now, say the customer wants to order a hamburger - I'll need to push additional data to the state object.

Updating State - useState Style

In plain old React, updating state is as simple as calling the setter exported by useState.

const [order, setOrder] = useState([]);

const addMenuItem = (food) => setOrder([...order, food])

And I can use the same approach with Recoil's analogous hook, useRecoilState:

const [order, setOrder] = useRecoilState(order);

const addMenuItem = (food) => setOrder([...order, food])

But what if my state mutation gets a little more complicated?

That's where reducers come in.

Updating State - useState + Reducer

Suppose the customer changed her mind and decided to cancel the hamburger?

To handle this slightly more complicated state update, I can create an immutable reducer function that takes the previous state, the change that I want to implement, and returns a new state object. Then, as before, I'll call the setter to update the state.

import { useRecoilValue } from 'recoil';

export default function OrderDisplay() {
    const [myOrder, setMyOrder] = useRecoilState(order);

    // reducer function
    const removeFoodItem = (currentOrder, foodToRemove) => {
        const foodToRemoveIndex = currentOrder.findIndex((val => val === foodToRemove));

        return [...currentOrder.slice(0, foodToRemoveIndex), ...currentOrder.slice(foodToRemoveIndex + 1)];
    }

    const onRemoveFood = (food) => () => {
        const newOrder = removeFoodItem(myOrder, food);
        setMyOrder(newOrder);
    }

    return (
        <div>
            <h3>Current Order:</h3>
            {myOrder.map((food, i) => {
                return (
                    <div key={i}>{food}
                        <span onClick={removeFoodItem(food)}>[x]</span>
                    </div>)
            })}
        </div>
    )
}

The thing is, now that I've defined a separate function to handle the update logic, it becomes clear that the function doesn't really belong in this view component. It's just kind of noise.

So let's extract it out:

const removeFoodItem = (currentOrder, foodToRemove) => {
    const foodToRemoveIndex = currentOrder.findIndex((val => val === foodToRemove));

    return [...currentOrder.slice(0, foodToRemoveIndex), ...currentOrder.slice(foodToRemoveIndex + 1)];
}
import { useRecoilValue } from 'recoil';

export default function OrderDisplay() {
    const [myOrder, setMyOrder] = useRecoilState(order);

    const onRemoveFood = (food) => () => {
        const newOrder = removeFoodItem(myOrder, food);
        setMyOrder(newOrder);
    }

    return (
        <div>
            <h3>Current Order:</h3>
            {myOrder.map((food, i) => {
                return (
                    <div key={i}>{food}
                        <span onClick={removeFoodItem(food)}>[x]</span>
                    </div>)
            })}
        </div>
    )
}

Much cleaner :)

Except that we need to explicitly feed this method the currentOrder from our React component, because a reducer is simply an immutable function. Let's see if we can make this even cleaner.

Using Selectors to Update Data

Let's start by looking at the regular selector. I'll define a selector that will return the total price for any order by cross-checking the food menu with a price list:

const priceList = [
    { name: 'cappucino', price: 5 },
    { name: 'latte', price: 7 },
    { name: 'espresso', price: 1.5 },
    { name: 'coffee', price: 4 },
    { name: 'cheesecake', price: 4 },
]

const order = atom({
    key: 'order',
    default: [],
});

const orderInfo = selector({
    key: 'orderTotalPrice',
    get: ({ get }) => {
        return get(order)
            .map(food => {
                const foodFromList = priceList.find(i => i.name === food)
                return foodFromList ? foodFromList.price : 0;
            })
            .reduce((sum, current) => sum + current, 0
      }
});

The selector defines a getter method that itself takes a get object as an argument and uses it to extract the order state object in order to cross-check it with our price-list, and then sum up the prices. It can be consumed just like we consume an atom - with the appropriate RecoilJS action hook™️ .

Updating State - Writeable Selectors

Now how about writeable selectors? A writeable selector is a selector that exposes a setter method in addition to a getter (yes, a selector can expose both).

We'll use a writeable selector to add food items to our menu (like what we did before using useState):

const addFood = selector({
    key: 'addFood',
    set: ({ set, get }, newFood) => {
        set(order, [...get(order), newFood])
    },
});

And now we would use the useSetRecoilState hook to consume this selector, and just call it with our new food of choice, à la:

const setAddFood = useSetRecoilState(addFood);

...
<div onClick={() => setAddFood(food)}>{food}</div>

Rather than putting our state modification logic in the view component, or using an immutable reducer method, we've declared a single method that can do two things:

  1. Access the data from our store, and
  2. Update the data exactly how we want

The elegance of this really shines when we apply it to the slightly-more-complicated removal of food items:

const removeFoodSelector = selector({
    key: 'removeFoodSelector',
    set: ({ set, get }, foodToRemove) => {
        const currentOrder = get(order);
        const foodToRemoveIndex = currentOrder.findIndex((val => val === foodToRemove));
        set([...currentOrder.slice(0, foodToRemoveIndex), ...currentOrder.slice(foodToRemoveIndex + 1)]);
    },
});

Look familiar? Yep, we've just combined the reducer logic with the ability to directly access our state object.

And now, finally, we can really clean up our view component:

import { useRecoilValue } from 'recoil';

export default function OrderDisplay() {
    const removeFoodItem = useSetRecoilState(removeFoodSelector);

    return (
        <div>
            <h3>Current Order:</h3>
            {myOrder.map((food, i) => {
                return (
                    <div key={i}>{food}
                        <span onClick={() => removeFoodItem(food)}>[x]</span>
                    </div>)
            })}
        </div>
    )
}

Notice how the final product abstracts away all state updating logic to where it belongs, i.e. where the model is defined and where it can access any other data that it requires without it having to accept it as a parameter from the view layer.

I'm not saying that writeable selectors are necessarily the best way to define state updating logic. How you architect your state management depends very much on system requirements and personal preference. But it is a compelling a choice.

Top comments (5)

Collapse
 
ogrotten profile image
ogrotten

Feedback: There are some key things left out that will leave questions for people new to recoil.

The best example is the last code block of the post where there are several imports that aren't made but are used in the code.

I think the best way to clear things up would be to have a codepen or the like with the complete version. Working would obviously be best, but even if not it will make for a better example.

Collapse
 
yoniweisbrod profile image
Yoni Weisbrod

Thanks a lot, working on it :)

Collapse
 
serge378 profile image
serge378 • Edited

In typescript they say Property 'get' is missing in removeFoodSelector
We can't only use 'set' Property, 'get' Property should be defined to compile the code
How can I resolve this, please ?
Because I want to share this selector in many components, so I don't want to copy and paste this many times If I use the "Updating State - useState + Reducer' method

Collapse
 
jthed profile image
JulianDeal

You can solve this by adding a get property to your selector:

const addFood = selector({
    key: 'addFood',
    get: ({ get }) => get(order), // <== THIS LINE HERE
    set: ({ set, get }, newFood) => {
        set(order, [...get(order), newFood])
    },
});
Enter fullscreen mode Exit fullscreen mode

More information on the corresponding Issue on GitHub.

Collapse
 
udai1931 profile image
Udai Gupta

Really helpful. Thanks : )