DEV Community

Cover image for Zustand EntityAdapter - A bookshelf example
Michael De Abreu
Michael De Abreu

Posted on

Zustand EntityAdapter - A bookshelf example

In the first post, we learned how to create an EntityAdapter for Zustand. In this one, we'll learn how to use it in our projects.

For this example, we want to manage a book collection, and we are going to use the books as entities. We'll use a clean React project, the React setup you choose is fine. In my case, I'm using StackBlitz, as I do with most of my experiments. This is not sponsored.

Creating the store

First, let's create the interface for each book.

Interface

interface Book {
  bookId: string;
  title: string;
}
Enter fullscreen mode Exit fullscreen mode

It's a simple interface, with two properties: the title and the bookId.

Note
I would like to showcase the idSelector, and that's why we are using another property to hold the entity ID.

With that interface, we can create our entityAdapter.

bookAdapter

To create our bookAdapter, let's create the idSelector function and use the createEntityAdapter function.

const idSelector = (book: Book) => book.bookId;

const bookAdapter = createEntityAdapter({ idSelector });
Enter fullscreen mode Exit fullscreen mode

If we weren't providing an idSelector, we would have to provide the Book interface as the generic type.

const bookAdapter = createEntityAdapter<Book>();
Enter fullscreen mode Exit fullscreen mode

With that, we will have the bookAdapter with the correct typings for all the methods.

Now we can use our bookAdapter to create the store.

useBookStore

To create the store itself, we have different alternatives. All using approaches that are present on the Zustand docs.

The first one is the basic approach to be used with Typescript.

import { create } from 'zustand';

...

type StoreState = EntityState<Book> & EntityActions<Book>;

export const useBookStore = create<StoreState>()((set) => {
  return {
    ...bookAdapter.getState(),
    ...bookAdapter.getActions(set),
  };
});
Enter fullscreen mode Exit fullscreen mode

It may be counterintuitive to call a function twice and declare the StoreState type, but as I mentioned, this is the recommended approach to use Zustand with Typescript.

The second one is an alternative option in the basic usage guide with Typescript using combine:

import { create } from 'zustand';
import { combine } from 'zustand/middleware';

...

export const useBookStore = create(
  combine(bookAdapter.getState(), bookAdapter.getActions)
);
Enter fullscreen mode Exit fullscreen mode

As you can see, the adapter we created is flexible enough for both approaches.

The last one is not suggested as part of the Zustand with Typescript usage, but a section called Practice with no store actions. This approach allows us to set our actions outside the store.

import { create } from 'zustand';

export const useBookStore = create(bookAdapter.getState);

export const bookActions = bookAdapter.getActions(useBookStore .setState);
Enter fullscreen mode Exit fullscreen mode

While the first two alternatives will create the same store, with actions within, the last one will create a store with the actions outside. There is no recommended way to do it, you can choose the one you prefer.

I'll use the last one for the rest of the example, but it won't affect much if you choose to use one of the first two.

Lastly, we will generate our selectors, using the bookAdapter.

export const bookSelectors = bookAdapter.getSelectors();
Enter fullscreen mode Exit fullscreen mode

If we put it all together, we will have this:

import { create } from 'zustand';
import { Book } from 'src/models/book';
import { createEntityAdapter } from 'src/zustand-entityadapter/creators';

const idSelector = (book: Book) => book.bookId;

const bookAdapter = createEntityAdapter({ idSelector });

export const useBookStore = create(bookAdapter.getState);

export const bookActions = bookAdapter.getActions(useBookStore.setState);

export const bookSelectors = bookAdapter.getSelectors();
Enter fullscreen mode Exit fullscreen mode

Let's create our components

Ok, so now we have all the ingredients to show our entities. But where are we going to show them? Well, in this case, I think what's important is to showcase how to interact with the store, so, let's just create a list component to show the books, a book component that allows us to edit the book, and one to add a book to our list.

BookList

Let's start creating our BookList component. Here we want to show all the books that are in the store.

import { FC } from 'react';
import { bookSelectors } from './useBookStore';
import { BookItem } from './BookItem';
import styles from './BookList.module.css';

const { selectAll } = bookSelectors;

export const BookList: FC = () => {
  const books = useBookStore(selectAll);
  return (
    <ul className={styles.BookList}>
      {books.map((book) => (
        <BookItem book={book} key={book.bookId} />
      ))}
    </ul>
  );
}
Enter fullscreen mode Exit fullscreen mode

As you can see, getting a list of our books is as simple as this.

Note
I won't go into styles, they are simple enough for this not to be too ugly. However, you can find the styles in the StackBlitz at the bottom.

BookItem

I think this one will be the more complex component. As you notice, this one will get the book as a prop. This component will be responsible for that book edition and removal.

import { ChangeEventHandler, FC } from 'react';
import { Book } from '../models/book';
import { bookActions } from './useBookStore';
import styles from './BookItem.module.css';

interface BookItemProps {
  book: Book;
}

const { updateOne, removeOne } = bookActions;

export const BookItem: FC<BookItemProps> = ({ book }) => {
  const handleChange: ChangeEventHandler<HTMLInputElement> = (e) => {
    const title = e.target.value;
    updateOne({ id: book.bookId, update: { title } });
  };

  return (
    <li className={styles.BookItem}>
      <>
        <form className={styles.BookItemForm}>
          <input type="text" onChange={handleChange} value={book.title} />
        </form>
        <button
          type="button"
          onClick={() => removeOne(book)}
          className={styles.BookItemAction}
        >
          Remove
        </button>
      </>
    </li>
  );
};
Enter fullscreen mode Exit fullscreen mode

We could improve this implementation with an edit/submit pattern, to avoid rendering on every change. But I want to focus here on the simplicity of this.

AddBook

From this component, we will allow the user to add a book to the list.

import { FC, FormEventHandler, useState } from 'react';
import { bookActions } from './useBookStore';
import styles from './AddBook.module.css';

let id = 1000;

const { addOne } = bookActions;

export const AddBook: FC = () => {
  const [title, setTitle] = useState('');

  const handleSubmit: FormEventHandler = (e) => {
    e.preventDefault();
    if (title.trim() === '') {
      return;
    }
    addOne({ bookId: `${id++}`, title });
    setTitle('');
  };

  return (
    <div className={styles.AddBook}>
      <form className={styles.AddBookForm} onSubmit={handleSubmit}>
        <input
          type="text"
          value={title}
          onChange={(e) => setTitle(e.target.value)}
        />
        <button className={styles.AddBookAction} type="submit">
          Add
        </button>
      </form>
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

This is a simple form to edit the book's name and submit it to the store.

Putting it all together

With all this, we can update our App component to use the components we have created:

import './App.css';
import { AddBook } from './components/AddBook';
import { BookList } from './components/BookList';

function App() {
  return (
    <>
      <BookList />
      <AddBook />
    </>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

This is where Zustand flexes. There is no Provider configuration, of any kind. Zustand doesn't need them, and we don't need them either.

I think this is powerful because it allows us to create stores close to the components that will be using those stores, again, just like you would do with a Service when you use Angular. I'm sure three folks will get that feeling, but I'm fine with it.

Adding a favorite book

So far, we have seen what we can do with our entity adapter, but what about those extra properties we want to handle as well?

Well, I think that one of the benefits of using Zustand is being able to manage little pieces of standalone data. For example, if I want to handle a favorite book, I could do something like:

export const useFavoriteBookStore = create<{ favorite?: Book }>(() => ({}));
Enter fullscreen mode Exit fullscreen mode

This will work, but for something simple like this, maybe it would be better to add it to the same store, right? On the other hand, depending on the complexity of the state you want to manage, it would be better to split it into its own store. That's why it's better to have a choice.

Extending the store

To extend the example, I'll show you how you could add your properties to the store that we have created. We will update our store code to:

export const useBookStore = create(
  devtools(() => ({
    ...bookAdapter.getState(),
    favorite: undefined as Book | undefined,
  }))
);
Enter fullscreen mode Exit fullscreen mode

Note:
We are casting here, because if we don't then TS won't infer this as Book | undefined. We could also use the generic parameter in create for this, and extend our state from EntityState.

Likewise, we need to update our actions to:

export const bookActions = {
  ...bookAdapter.getActions(useBookStore.setState),
  setFavorite(favorite: Book) {
    useBookStore.setState({ favorite });
  },
  resetFavorite() {
    useBookStore.setState({ favorite: undefined });
  },
};
Enter fullscreen mode Exit fullscreen mode

And that's all! We have all what we need to show what is our favorite book.

Updating the components

To allow users to select their favorite book, we only need a small update to the BookItem component.


const { setFavorite } = bookActions;

export const BookItem: FC<BookItemProps> = ({ book }) => {
  ...
  return (
    ... 
        // input
        <button type="button" onClick={() => setFavorite(book)}>
          ❤️
        </button>
        // reset button
    ...
  );
}
Enter fullscreen mode Exit fullscreen mode

To show the favorite book, we do a small update on the BookList component.

...
const { resetFavorite } = bookActions;
...

export const BookList: FC = () => {
  ...
  const favoriteBook = useBookStore((state) => state.favorite);

  ...

  return (
    <> // opening fragment
      {favoriteBook && (
        <div>
          Favorite book is: {favoriteBook.title}{' '}
          <button onClick={resetFavorite}>💔</button>
        </div>
      )}
    // rest of the list
    ...
  );
};
Enter fullscreen mode Exit fullscreen mode

With that, you'll be able to select a favorite book!

Check out what you have done!

Here is a link to the StackBlitz project of this example and you can also check it out in the embed view.

That's all folks!

Thank you! I hope this can be something you find useful, let me know in the comments what you think about this approach. I'm using this series as a showcase for a formal proposal for this to be introduced in Zustand, and here is the link to the discussion, feel free to send your feedback in the repo as well.

Image generated with Microsoft Designer AI A bear looking for a book on a shelf, in a living room with a sofa and firewood, with a 16 bits palette

One more thing!

While this example is simple enough, there is a feature on the EntityAdapter that this implementation is missing, and that is sorting. I didn't want to include it here, because it adds another layer of complexity I didn't want to bring.

Lucky you, I have an implementation in StackBlitz with sorting and additional features. This is the same implementation used in the proposal discussion, you will find more information about it there.

Here is the link to the Zustand Discussion of the proposal

Here is a link to the StackBlitz project of the proposal implementation and you can also check it out in the embed view.

Top comments (2)

Collapse
 
hendrikras profile image
Hendrik Ras

Why does the example not work for me?

Image description

Collapse
 
michaeljota profile image
Michael De Abreu

I don't know why it's not working. I added a link to the Stackblitz project so you can interact with it directly. Thank you!