Build a Bookmark Manager with Zustand
Build a React 19 bookmark manager with Zustand state and localStorage persistence.
Introduction
30 Second Summary
You probably have dozens of useful links scattered across browser tabs, notes apps, and messaging threads. Finding that one tutorial or article when you actually need it feels impossible.
In this project, you will build a personal bookmark manager app that saves URLs with titles and tags, lets you search and filter your collection, and persists everything locally so your bookmarks survive page refreshes.
What You'll Build
A bookmark manager built with React 19, Zustand for state management, and Tailwind CSS for styling.
By the end of this project, you'll have:
- A Vite-scaffolded React 19 project with Tailwind CSS v4 running locally.
- A Zustand store managing bookmarks with add, delete, search, and tag-filter actions.
- A responsive UI with an add-bookmark form, tag filter buttons, a search bar, and bookmark cards.
- localStorage persistence via Zustand's persist middleware so bookmarks survive browser closes.
- Secret Mission: Add the ability to edit existing bookmarks inline and mark bookmarks as favourites with a priority sort.
Are there any prerequisites?
You should have basic JavaScript knowledge and some familiarity with React concepts like components, props, and JSX. No prior experience with Zustand or Tailwind CSS is needed.
Before We Start
Before We Start...
Scaffold the Project and Install Dependencies
Every React app needs a foundation. You need a build tool to bundle your code, a styling system, and a state management library.
In this step, you will scaffold a React project using Vite, then install Tailwind CSS for styling and Zustand for state management.
In this step, get ready to:
- Create a Vite + React project.
- Install Zustand and Tailwind CSS.
- Configure Tailwind and verify the dev server.
Create a new Vite + React project
Vite is a modern build tool that starts a dev server in milliseconds and hot-reloads your changes instantly. The react template gives you a ready-to-go React project with no extra configuration.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project by running this command:
npm create vite@latest bookmark-manager -- --template react
What does this command do?
- npm create vite@latest downloads and runs the latest Vite project scaffolding tool.
- bookmark-manager is the name of the folder it creates.
- -- --template react tells Vite to use the React template, skipping any interactive prompts.
- Move into your new project folder by running:
cd bookmark-manager
- Confirm the project files exist by running:
ls
- You should see files like index.html, package.json, vite.config.js, and a src folder in the output.
Don't see those files?
- Make sure you included the double dash -- before --template react. Without it, npm does not pass the flag to Vite.
- Check that you ran cd bookmark-manager after creating the project. The ls command only shows contents of your current folder.
- Help me troubleshoot my Vite project creation.
Install Tailwind CSS and Zustand
The scaffolded project has React listed in package.json, but the packages are not downloaded yet. You also need to add Tailwind CSS for styling and Zustand for state management.
- Install the base project dependencies by running:
npm install
- Install Tailwind CSS, the Tailwind Vite plugin, and Zustand by running:
npm install tailwindcss @tailwindcss/vite zustand
- You should see a success message with the number of packages added. No errors or warnings about peer dependencies should appear.
Seeing errors during install?
- Check that you are inside the bookmark-manager folder. Run pwd to confirm your current location.
- Make sure your Node.js version is 22 or higher. Run node --version to check.
- Help me fix my npm install errors.
Configure Tailwind and start the dev server
Tailwind CSS v4 integrates with Vite through a plugin. You need to register it in your Vite config and point your CSS file at Tailwind.
- Open vite.config.js in your code editor.
- Replace the entire contents of the file with this code:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [
react(),
tailwindcss(),
],
})
What does this config do?
- react() enables React Fast Refresh so your components hot-reload without losing state.
- tailwindcss() processes your Tailwind utility classes at build time. This is all you need for Tailwind v4 with Vite.
- Save vite.config.js.
- Now open src/index.css and delete everything in it.
- Replace the file contents with this single line:
@import "tailwindcss";
Why just one line?
Tailwind CSS v4 uses a single @import "tailwindcss" directive to load all of its utility classes, preflight reset, and theme tokens. No config file needed.
- Save src/index.css.
- Start the dev server by running this command in your terminal:
npm run dev
- Open http://localhost:5173 in your browser. You should see the default Vite + React page with the React logo and a counter button.
How do I know Tailwind is working?
The page styling will look slightly different from a standard Vite scaffold because Tailwind's preflight CSS reset is now active. Text may appear unstyled and margins may collapse. That is Tailwind working correctly.
Page not loading at localhost:5173?
- Check your terminal for error messages. A typo in vite.config.js (like a missing comma after react()) will prevent the server from starting.
- Make sure src/index.css contains only @import "tailwindcss"; with no leftover CSS from the scaffold.
- Help me debug my Vite dev server startup.
✔️ Awesome, I've got everything!
Great. Double check you have saved both vite.config.js and src/index.css before moving on.
ⓧ I'd like to double check the full code
Here are the three key files you should have at this point.
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [
react(),
tailwindcss(),
],
})
@import "tailwindcss";
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)
Your project is scaffolded, styled, and running. Next up, you will create the Zustand store that manages all of your bookmark data.
Create the Zustand Bookmark Store
Your project is scaffolded, your dependencies are installed, and your dev server is running. Now it is time to build the brain of your bookmark manager.
Every bookmark you save needs somewhere to live. Zustand gives you a single store that holds all your app's data and the functions to change it. You will create a store that manages bookmarks, handles searching, and persists everything to localStorage so nothing disappears when you close your browser.
In this step, get ready to:
- Create a Zustand store with state for bookmarks, search queries, and tag filters.
- Add localStorage persistence so bookmarks survive page refreshes.
- Build a getter function that filters bookmarks by search text and tag.
Create the store file with state and actions
A Zustand store is a single JavaScript file that holds your app's shared state and the functions (called actions) to update that state. Any component can read from or write to this store without prop drilling.
- In the Explorer sidebar, right-click the src folder and select New Folder.
- Name the folder store.
- Right-click the new store folder and select New File.
- Name the file bookmarkStore.js.
- Paste the following code into src/store/bookmarkStore.js:
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
const useBookmarkStore = create(
persist(
(set, get) => ({
bookmarks: [],
searchQuery: '',
filterTag: '',
addBookmark: (bookmark) =>
set((state) => ({
bookmarks: [
...state.bookmarks,
{
id: Date.now().toString(),
title: bookmark.title,
url: bookmark.url,
tags: bookmark.tags,
isFavourite: false,
createdAt: new Date().toISOString(),
},
],
})),
deleteBookmark: (id) =>
set((state) => ({
bookmarks: state.bookmarks.filter((b) => b.id !== id),
})),
setSearchQuery: (query) => set({ searchQuery: query }),
setFilterTag: (tag) => set({ filterTag: tag }),
}),
{
name: 'bookmark-storage',
}
)
)
export default useBookmarkStore
What does this code do?
This file creates a Zustand store that manages your bookmark data. Here is what each piece does:
- bookmarks is an array that holds all your saved bookmark objects.
- searchQuery and filterTag track what the user is currently searching or filtering by.
- addBookmark creates a new bookmark object with a unique ID, timestamp, and the data the user entered.
- deleteBookmark removes a bookmark by filtering it out of the array by its ID.
- The persist wrapper automatically saves the entire store to localStorage under the key bookmark-storage.
- Save src/store/bookmarkStore.js.
- Check your code editor for any red underlines or syntax errors in the file. If you see none, the store structure is valid.
Seeing red underlines in your store file?
- Check that every opening parenthesis and curly brace has a matching close. The persist() wrapper adds an extra layer of nesting that is easy to mismatch.
- Make sure the import paths are exact: zustand for create and zustand/middleware for persist.
- Confirm you installed zustand in Step 1. If not, run npm install zustand in your terminal.
Help me debug my Zustand store file
Understand the persist middleware
Notice the persist wrapper around your store's state and actions. This is Zustand's built-in middleware for saving your store to the browser's localStorage.
What is the persist middleware?
Without persist, your bookmarks would vanish every time you refresh the page. The persist middleware solves this by automatically saving the store to localStorage after every state change.
When the app loads, persist checks localStorage for a key called bookmark-storage and rehydrates the store with that saved data. Your bookmarks survive refreshes, tab closes, and even browser restarts.
Why Zustand instead of React Context?
React Context re-renders every consumer whenever any part of the context value changes. For a list-heavy app like a bookmark manager, this means typing a single character in the search bar would re-render every bookmark card.
Zustand uses selectors so each component only re-renders when the specific slice of state it reads actually changes. This keeps your app snappy as your bookmark list grows.
Add the filtered bookmarks getter
Your store needs a way to return only the bookmarks that match the current search query and tag filter. Instead of filtering inside each component, you will add a getter function directly in the store.
- In src/store/bookmarkStore.js, find the setFilterTag action.
- Add the following getFilteredBookmarks function directly below it (still inside the store object):
getFilteredBookmarks: () => {
const { bookmarks, searchQuery, filterTag } = get()
let filtered = bookmarks
if (searchQuery) {
const query = searchQuery.toLowerCase()
filtered = filtered.filter(
(b) =>
b.title.toLowerCase().includes(query) ||
b.url.toLowerCase().includes(query)
)
}
if (filterTag) {
filtered = filtered.filter((b) => b.tags.includes(filterTag))
}
return filtered
},
What does this getter do?
This function reads the current state using get() and applies two filters in sequence:
- If searchQuery has a value, it keeps only bookmarks whose title or URL contains that text (case-insensitive).
- If filterTag has a value, it keeps only bookmarks that include that tag in their tags array.
Components will call this getter instead of reading the raw bookmarks array directly. This keeps your filtering logic in one place.
- Save src/store/bookmarkStore.js.
- Check the terminal where npm run dev is running. If you see no new errors, the file is syntactically valid.
Seeing a syntax error in the terminal?
- Make sure you added a comma after the setFilterTag action and before getFilteredBookmarks. Missing commas between object properties are the most common cause.
- Check that the trailing comma after the closing brace of getFilteredBookmarks is present. It should end with }, not just }.
- Verify the function uses get() (provided by Zustand's second argument to the store callback) to read state.
Help me fix my getFilteredBookmarks function
✔️ Awesome, I've got everything!
Great. Double check you have saved your file before moving on.
ⓧ I'd like to double check the full code
Here is the complete src/store/bookmarkStore.js file. Compare it against yours line by line:
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
const useBookmarkStore = create(
persist(
(set, get) => ({
bookmarks: [],
searchQuery: '',
filterTag: '',
addBookmark: (bookmark) =>
set((state) => ({
bookmarks: [
...state.bookmarks,
{
id: Date.now().toString(),
title: bookmark.title,
url: bookmark.url,
tags: bookmark.tags,
isFavourite: false,
createdAt: new Date().toISOString(),
},
],
})),
deleteBookmark: (id) =>
set((state) => ({
bookmarks: state.bookmarks.filter((b) => b.id !== id),
})),
setSearchQuery: (query) => set({ searchQuery: query }),
setFilterTag: (tag) => set({ filterTag: tag }),
getFilteredBookmarks: () => {
const { bookmarks, searchQuery, filterTag } = get()
let filtered = bookmarks
if (searchQuery) {
const query = searchQuery.toLowerCase()
filtered = filtered.filter(
(b) =>
b.title.toLowerCase().includes(query) ||
b.url.toLowerCase().includes(query)
)
}
if (filterTag) {
filtered = filtered.filter((b) => b.tags.includes(filterTag))
}
return filtered
},
}),
{
name: 'bookmark-storage',
}
)
)
export default useBookmarkStore
Your store is ready and waiting. It knows how to add bookmarks, delete them, search, and filter. Next up, you will build the form component that lets you actually save a bookmark into this store.
Build the Add Bookmark Form
Your Zustand store knows how to manage bookmarks, but there is no way for users to actually add one yet.
In this step, you will build a form component with controlled inputs that connects directly to your store's addBookmark action. When a user submits the form, the bookmark lands in the store and persists to localStorage automatically.
In this step, get ready to:
- Create a form component with controlled React inputs.
- Style the form with Tailwind CSS utility classes.
- Add validation and form clearing after submission.
Create the AddBookmarkForm component
A controlled input is a form field whose value is driven entirely by React state. Every keystroke updates state, and the input always reflects that state.
- In the Explorer sidebar of Google Antigravity, right-click the src folder and select New Folder.
- Name the folder components.
- Right-click the new components folder and select New File.
- Name the file AddBookmarkForm.jsx.
- Paste the following code into AddBookmarkForm.jsx:
import { useState } from 'react'
import useBookmarkStore from '../store/bookmarkStore'
function AddBookmarkForm() {
const [url, setUrl] = useState('')
const [title, setTitle] = useState('')
const [tags, setTags] = useState('')
const addBookmark = useBookmarkStore((state) => state.addBookmark)
const handleSubmit = (e) => {
e.preventDefault()
if (!url.trim() || !title.trim()) return
const tagList = tags
.split(',')
.map((tag) => tag.trim())
.filter((tag) => tag !== '')
addBookmark({ url: url.trim(), title: title.trim(), tags: tagList })
setUrl('')
setTitle('')
setTags('')
}
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-3 p-4 bg-white rounded-lg shadow-sm">
<h2 className="text-lg font-semibold text-gray-800">Add Bookmark</h2>
<input
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://example.com"
required
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Bookmark title"
required
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<input
type="text"
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder="Tags (comma-separated)"
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white font-medium rounded-md hover:bg-blue-700 transition-colors"
>
Add Bookmark
</button>
</form>
)
}
export default AddBookmarkForm
What does this code do?
Three useState hooks create controlled inputs for url, title, and tags. Each input's value is tied to state, and onChange updates that state on every keystroke.
The handleSubmit function prevents the default form reload, checks that URL and title are not empty, splits the tags string by commas into an array, and calls addBookmark from the store. After a successful submission, all three inputs are cleared back to empty strings.
The useBookmarkStore((state) => state.addBookmark) selector subscribes only to the addBookmark action. This means the component will not re-render when other parts of the store change.
- Save AddBookmarkForm.jsx.
- Check your terminal where the dev server is running. You should see no errors in the output.
Seeing a compile error?
- Make sure the file is inside src/components/ and named exactly AddBookmarkForm.jsx (capital A, B, F).
- Check that the import path '../store/bookmarkStore' has two dots. The components folder is one level deeper than store.
- help me fix a compile error in my AddBookmarkForm component
Preview the form in your browser
The component exists but is not rendered anywhere yet. To see the Tailwind CSS styling in action, you need to temporarily import it into App.jsx.
- Open src/App.jsx in Google Antigravity.
- Replace the entire contents of the file with this temporary test code:
import AddBookmarkForm from './components/AddBookmarkForm'
function App() {
return (
<div className="min-h-screen bg-gray-50 py-8 px-4">
<AddBookmarkForm />
</div>
)
}
export default App
What do the Tailwind classes do?
- flex flex-col gap-3 stacks the inputs vertically with consistent spacing.
- p-4 bg-white rounded-lg shadow-sm gives the form a card-like appearance with padding, a white background, rounded corners, and a subtle shadow.
- focus:ring-2 focus:ring-blue-500 adds a visible blue ring when an input is focused, improving accessibility.
- bg-blue-600 hover:bg-blue-700 transition-colors makes the submit button blue with a darker hover state and a smooth colour transition.
- Save App.jsx and switch to your browser at http://localhost:5173.
- You should see a white card with the heading "Add Bookmark", three input fields, and a blue "Add Bookmark" button on a light grey background.
Don't see the form?
- Make sure you saved both AddBookmarkForm.jsx and App.jsx.
- Confirm the import path in App.jsx is './components/AddBookmarkForm' (one dot, not two).
- help me debug why my AddBookmarkForm is not rendering in the browser
Test the validation and form clearing
The form has two layers of protection. The required attribute on the URL and title inputs triggers the browser's built-in validation, and the if (!url.trim() || !title.trim()) return check in handleSubmit catches whitespace-only submissions.
- Click the Add Bookmark button without typing anything. Your browser should show a validation tooltip on the URL field.
- Now fill in a URL (like https://react.dev), a title (like React Docs), and some tags (like react, docs, frontend).
- Click Add Bookmark. All three inputs should clear immediately after submission.
- Open your browser's DevTools by pressing Cmd+Option+I (macOS) or Ctrl+Shift+I (Windows).
- Switch to the Application tab, expand Local Storage, and click http://localhost:5173.
- You should see a bookmark-storage key. Click it to see your bookmark saved with the title, URL, and tags array.
Why check localStorage?
The persist middleware you set up in the previous step automatically serializes your store to localStorage under the key bookmark-storage. Seeing your bookmark there confirms the entire flow works: form submission, store update, and persistence.
Form clears but nothing in localStorage?
- Open the browser console and check for errors. A typo in the store's persist config can silently break persistence.
- Verify that bookmarkStore.js includes name: 'bookmark-storage' inside the persist options object.
- help me debug why my Zustand persist middleware is not saving to localStorage
✔️ Awesome, I've got everything!
Great. Make sure you have saved AddBookmarkForm.jsx before moving on.
ⓧ I'd like to double check the full code
Here is the complete src/components/AddBookmarkForm.jsx file:
import { useState } from 'react'
import useBookmarkStore from '../store/bookmarkStore'
function AddBookmarkForm() {
const [url, setUrl] = useState('')
const [title, setTitle] = useState('')
const [tags, setTags] = useState('')
const addBookmark = useBookmarkStore((state) => state.addBookmark)
const handleSubmit = (e) => {
e.preventDefault()
if (!url.trim() || !title.trim()) return
const tagList = tags
.split(',')
.map((tag) => tag.trim())
.filter((tag) => tag !== '')
addBookmark({ url: url.trim(), title: title.trim(), tags: tagList })
setUrl('')
setTitle('')
setTags('')
}
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-3 p-4 bg-white rounded-lg shadow-sm">
<h2 className="text-lg font-semibold text-gray-800">Add Bookmark</h2>
<input
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://example.com"
required
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Bookmark title"
required
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<input
type="text"
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder="Tags (comma-separated)"
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white font-medium rounded-md hover:bg-blue-700 transition-colors"
>
Add Bookmark
</button>
</form>
)
}
export default AddBookmarkForm
Your form is ready to accept bookmarks and persist them. Next up, you will build the bookmark cards and wire up search and tag filtering so you can actually see and find your saved links.
Display Bookmarks and Implement Search and Filter
Your add-bookmark form is working and writing data into the Zustand store. The problem is that bookmarks disappear into a void. There is no way to see them, search them, or filter by tag.
In this step, you will build the display layer. You will create components that render bookmark cards, a search bar for instant filtering, and tag buttons that narrow results to a single category.
In this step, get ready to:
- Create a BookmarkCard and BookmarkList to render saved bookmarks.
- Create a SearchBar and TagFilter for real-time filtering.
- Wire all components into App.jsx with a polished layout.
Create the BookmarkCard and BookmarkList components
Each bookmark needs a visual representation. You will build a card component for individual bookmarks, then a list component that pulls filtered results from the store and maps over them.
- Create a new file at src/components/BookmarkCard.jsx and add this code:
import useBookmarkStore from '../store/bookmarkStore'
function BookmarkCard({ bookmark }) {
const deleteBookmark = useBookmarkStore((state) => state.deleteBookmark)
return (
<div className="p-4 bg-white rounded-lg shadow-sm border border-gray-100 flex flex-col gap-2">
<div className="flex justify-between items-start">
<a
href={bookmark.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 font-medium hover:underline text-lg"
>
{bookmark.title}
</a>
<button
onClick={() => deleteBookmark(bookmark.id)}
className="text-red-500 hover:text-red-700 text-sm font-medium"
>
Delete
</button>
</div>
<p className="text-gray-500 text-sm truncate">{bookmark.url}</p>
{bookmark.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{bookmark.tags.map((tag) => (
<span
key={tag}
className="px-2 py-0.5 bg-blue-100 text-blue-700 text-xs rounded-full"
>
{tag}
</span>
))}
</div>
)}
</div>
)
}
export default BookmarkCard
What does this code do?
BookmarkCard receives a single bookmark object as a prop and renders it as a styled card.
- The title renders as a clickable link that opens in a new tab using target="_blank".
- A red Delete button calls deleteBookmark from the store, removing that bookmark by ID.
- Tags render as blue pill-shaped spans using Tailwind's rounded-full class.
- Now create src/components/BookmarkList.jsx and add this code:
import useBookmarkStore from '../store/bookmarkStore'
import BookmarkCard from './BookmarkCard'
function BookmarkList() {
const getFilteredBookmarks = useBookmarkStore((state) => state.getFilteredBookmarks)
const bookmarks = getFilteredBookmarks()
if (bookmarks.length === 0) {
return (
<p className="text-center text-gray-500 py-8">
No bookmarks found. Add one above!
</p>
)
}
return (
<div className="flex flex-col gap-3">
{bookmarks.map((bookmark) => (
<BookmarkCard key={bookmark.id} bookmark={bookmark} />
))}
</div>
)
}
export default BookmarkList
What does this code do?
BookmarkList calls getFilteredBookmarks() from the store, which returns only the bookmarks matching the current search query and tag filter.
If no bookmarks match, it shows an empty state message. Otherwise it maps each bookmark into a BookmarkCard component.
- Save both BookmarkCard.jsx and BookmarkList.jsx.
- You won't see a change yet. These components need to be imported into App.jsx before they render.
Seeing a red error in the terminal?
- Check that the import path in BookmarkList matches exactly: '../store/bookmarkStore'. A wrong relative path causes a module-not-found error.
- Confirm both files are inside the src/components/ folder with the exact filenames above.
Why aren't my bookmark components rendering?
Create the SearchBar and TagFilter components
The store already has searchQuery and filterTag state, plus actions to update them. Now you need UI components that connect to those store slices.
- Create a new file at src/components/SearchBar.jsx and add this code:
import useBookmarkStore from '../store/bookmarkStore'
function SearchBar() {
const searchQuery = useBookmarkStore((state) => state.searchQuery)
const setSearchQuery = useBookmarkStore((state) => state.setSearchQuery)
return (
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search bookmarks..."
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
)
}
export default SearchBar
What does this code do?
SearchBar is a controlled input. It reads searchQuery from the store for its value and calls setSearchQuery on every keystroke. Because BookmarkList uses getFilteredBookmarks, typing here instantly filters the displayed results.
- Create src/components/TagFilter.jsx and add this code:
import useBookmarkStore from '../store/bookmarkStore'
function TagFilter() {
const bookmarks = useBookmarkStore((state) => state.bookmarks)
const filterTag = useBookmarkStore((state) => state.filterTag)
const setFilterTag = useBookmarkStore((state) => state.setFilterTag)
const allTags = [...new Set(bookmarks.flatMap((b) => b.tags))]
return (
<div className="flex flex-wrap gap-2">
<button
onClick={() => setFilterTag('')}
className={`px-3 py-1 rounded-full text-sm font-medium transition-colors ${
filterTag === ''
? 'bg-blue-600 text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
>
All
</button>
{allTags.map((tag) => (
<button
key={tag}
onClick={() => setFilterTag(filterTag === tag ? '' : tag)}
className={`px-3 py-1 rounded-full text-sm font-medium transition-colors ${
filterTag === tag
? 'bg-blue-600 text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
>
{tag}
</button>
))}
</div>
)
}
export default TagFilter
What does this code do?
- It extracts every unique tag from all bookmarks using flatMap and a Set.
- An "All" button sets filterTag to an empty string, which shows everything.
- Clicking an active tag again clears the filter (toggling behaviour) because the onClick checks filterTag === tag ? '' : tag.
- Save both SearchBar.jsx and TagFilter.jsx.
- You won't see a change yet. The next substep wires these into App.jsx where they become visible.
Getting a syntax error in TagFilter?
- Template literals inside JSX className use backticks, not regular quotes. Make sure the className uses {`...`} syntax.
- Check that the ternary inside the template literal has matching single quotes around class name strings.
Help me fix the TagFilter syntax error.
Wire everything together in App.jsx
All four components are ready. Now you need to import them into App.jsx and arrange them in a clean layout with search and filter at the top, the form below, and results at the bottom.
- Replace the entire contents of src/App.jsx with this code:
import AddBookmarkForm from './components/AddBookmarkForm'
import SearchBar from './components/SearchBar'
import TagFilter from './components/TagFilter'
import BookmarkList from './components/BookmarkList'
function App() {
return (
<div className="min-h-screen bg-gray-50 py-8 px-4">
<div className="max-w-2xl mx-auto flex flex-col gap-6">
<h1 className="text-3xl font-bold text-gray-900 text-center">
Bookmark Manager
</h1>
<SearchBar />
<TagFilter />
<AddBookmarkForm />
<BookmarkList />
</div>
</div>
)
}
export default App
What does this layout do?
- The outer div sets a full-height grey background with padding.
- The inner div caps width at max-w-2xl and centres it horizontally with mx-auto.
- Components stack vertically with consistent spacing using flex flex-col gap-6.
- Save App.jsx.
- Switch to your browser at http://localhost:5173. You should see the heading "Bookmark Manager", an empty search bar, an "All" tag button, the add-bookmark form, and a "No bookmarks found" message.
- Add a bookmark using the form (e.g. title: React Docs, URL: https://react.dev, tags: docs, react). It should appear as a card below the form.
- Add 2-3 more bookmarks with different tags, then type in the search bar to filter by title or URL.
- Click a tag button to filter by that tag. Click the same tag again (or click All) to clear the filter.
Don't see your components?
- Check that each import path in App.jsx starts with ./components/ and matches the exact filename (case-sensitive).
- If the page is blank, open the browser console (F12) and look for error messages about missing modules or failed imports.
- Make sure every file has the export default line at the bottom. A missing export causes a blank render with no error.
Help me debug why my bookmark app page is blank.
✔️ Awesome, I've got everything!
Great. Make sure all five files are saved before moving on.
ⓧ I'd like to double check the full code
import useBookmarkStore from '../store/bookmarkStore'
function BookmarkCard({ bookmark }) {
const deleteBookmark = useBookmarkStore((state) => state.deleteBookmark)
return (
<div className="p-4 bg-white rounded-lg shadow-sm border border-gray-100 flex flex-col gap-2">
<div className="flex justify-between items-start">
<a
href={bookmark.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 font-medium hover:underline text-lg"
>
{bookmark.title}
</a>
<button
onClick={() => deleteBookmark(bookmark.id)}
className="text-red-500 hover:text-red-700 text-sm font-medium"
>
Delete
</button>
</div>
<p className="text-gray-500 text-sm truncate">{bookmark.url}</p>
{bookmark.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{bookmark.tags.map((tag) => (
<span
key={tag}
className="px-2 py-0.5 bg-blue-100 text-blue-700 text-xs rounded-full"
>
{tag}
</span>
))}
</div>
)}
</div>
)
}
export default BookmarkCard
import useBookmarkStore from '../store/bookmarkStore'
import BookmarkCard from './BookmarkCard'
function BookmarkList() {
const getFilteredBookmarks = useBookmarkStore((state) => state.getFilteredBookmarks)
const bookmarks = getFilteredBookmarks()
if (bookmarks.length === 0) {
return (
<p className="text-center text-gray-500 py-8">
No bookmarks found. Add one above!
</p>
)
}
return (
<div className="flex flex-col gap-3">
{bookmarks.map((bookmark) => (
<BookmarkCard key={bookmark.id} bookmark={bookmark} />
))}
</div>
)
}
export default BookmarkList
import useBookmarkStore from '../store/bookmarkStore'
function SearchBar() {
const searchQuery = useBookmarkStore((state) => state.searchQuery)
const setSearchQuery = useBookmarkStore((state) => state.setSearchQuery)
return (
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search bookmarks..."
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
)
}
export default SearchBar
import useBookmarkStore from '../store/bookmarkStore'
function TagFilter() {
const bookmarks = useBookmarkStore((state) => state.bookmarks)
const filterTag = useBookmarkStore((state) => state.filterTag)
const setFilterTag = useBookmarkStore((state) => state.setFilterTag)
const allTags = [...new Set(bookmarks.flatMap((b) => b.tags))]
return (
<div className="flex flex-wrap gap-2">
<button
onClick={() => setFilterTag('')}
className={`px-3 py-1 rounded-full text-sm font-medium transition-colors ${
filterTag === ''
? 'bg-blue-600 text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
>
All
</button>
{allTags.map((tag) => (
<button
key={tag}
onClick={() => setFilterTag(filterTag === tag ? '' : tag)}
className={`px-3 py-1 rounded-full text-sm font-medium transition-colors ${
filterTag === tag
? 'bg-blue-600 text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
>
{tag}
</button>
))}
</div>
)
}
export default TagFilter
import AddBookmarkForm from './components/AddBookmarkForm'
import SearchBar from './components/SearchBar'
import TagFilter from './components/TagFilter'
import BookmarkList from './components/BookmarkList'
function App() {
return (
<div className="min-h-screen bg-gray-50 py-8 px-4">
<div className="max-w-2xl mx-auto flex flex-col gap-6">
<h1 className="text-3xl font-bold text-gray-900 text-center">
Bookmark Manager
</h1>
<SearchBar />
<TagFilter />
<AddBookmarkForm />
<BookmarkList />
</div>
</div>
)
}
export default App
Why does filtering feel instant?
Zustand uses selectors to subscribe components to only the state slices they need. When you type in the search bar, only searchQuery changes. BookmarkList re-renders because it calls getFilteredBookmarks, but AddBookmarkForm does not re-render because it only subscribes to addBookmark.
This surgical re-rendering is why Zustand scales better than React Context for list-heavy apps.
Secret mission
Edit Bookmarks and Favourites
Your bookmark manager saves and filters links, but what if you make a typo? Or want to pin your most-used bookmarks to the top? In this mission, you'll add inline editing and a favourites system with priority sorting.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- The bookmark-manager project folder on your Desktop.
- The Vite dev server (running process in your terminal).
- localStorage data stored under the bookmark-storage key in your browser.
Keep everything running
No action needed. Choose this if you're still actively building or want to keep experimenting with your bookmark manager.
- Your Vite dev server stays running in the terminal. You can access your app at http://localhost:5173 any time.
- Your bookmarks persist in localStorage automatically. They survive browser closes and page refreshes.
- The bookmark-manager folder remains on your Desktop ready for future edits.
Pause - I'll come back to this later
Shut down the running dev server to free up your terminal. Your project files and bookmarks stay intact for when you return.
- In the terminal where the Vite dev server is running, press Ctrl+C to stop it.
- Your bookmark-manager folder stays on your Desktop. To resume later, open the folder and run npm run dev again.
- Your bookmarks remain saved in localStorage. They will still be there when you restart the dev server.
Delete - I don't want to use this again
Remove all project files and stored data to start completely fresh.
- In the terminal where the Vite dev server is running, press Ctrl+C to stop it.
- Delete the project folder by running this command from your Desktop:
macOS
cd ~/Desktop
rm -rf bookmark-manager
Windows
cd ~\Desktop
rmdir /s /q bookmark-manager
- Clear the localStorage data by opening your browser's developer tools (Cmd+Option+I on macOS or Ctrl+Shift+I on Windows), going to the Application tab, expanding Local Storage, selecting http://localhost:5173, and deleting the bookmark-storage entry.
Nice Work!
Nice Work!
You just built a fully functional bookmark manager from scratch using the modern React stack. Your app saves, searches, filters, and persists data across browser sessions.
You've learned how to:
- Build a complete React 19 app from scratch using Vite as the build tool.
- Create a Zustand store with actions, selectors, and the persist middleware for automatic localStorage saving.
- Implement real-time search and tag filtering with derived state using Zustand's get() function.
- Secret Mission: Added inline editing and a favourites system with priority sorting.
Ready to quiz yourself?