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;
}
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 });
If we weren't providing an idSelector
, we would have to provide the Book
interface as the generic type.
const bookAdapter = createEntityAdapter<Book>();
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),
};
});
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)
);
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);
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();
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();
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>
);
}
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>
);
};
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>
);
};
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;
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 }>(() => ({}));
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,
}))
);
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 });
},
};
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
...
);
}
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
...
);
};
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.