MERN Anime Watchlist with RTK Query
Build a MERN stack anime watchlist app with Redux Toolkit and RTK Query.
Introduction
30 Second Summary
Keeping track of every anime you want to watch, are currently watching, or have already finished can feel impossible when your list spans hundreds of titles across different genres and seasons.
In this project, you will build a full-stack anime watchlist application that searches real anime titles from MyAnimeList data, saves them to a personal watchlist with status categories and ratings, and persists everything to a cloud database.
What You'll Build
A MERN stack anime watchlist app using React, Redux Toolkit, Express, Mongoose, and MongoDB Atlas with RTK Query managing all data fetching, caching, and cache invalidation.
By the end of this project, you'll have:
- A React + TypeScript frontend with two RTK Query API slices fetching data from both an external anime API and your own backend.
- A Node.js + Express REST API with full CRUD operations backed by Mongoose and MongoDB Atlas.
- A responsive watchlist UI with anime search, status filtering, rating controls, and tag-based cache invalidation that keeps your data in sync without manual refreshes.
- Secret Mission: Add client-side filtering and sorting to your watchlist by status and rating.
Are there any prerequisites?
You should have experience building React components and a basic understanding of REST APIs. You will also need a MongoDB Atlas account (free tier) for storing your watchlist data.
Set Up the Project
To build a full-stack anime watchlist app, you need two things running at once: a React frontend that displays the UI, and a Node.js backend that stores your data.
In this step, you will scaffold both halves of the project using Vite for the frontend and Express for the backend. By the end, both servers will be running locally and talking to each other.
In this step, get ready to:
- Scaffold a Vite React TypeScript project and install frontend dependencies.
- Create an Express backend with a health check endpoint.
- Verify both servers run concurrently and communicate through a proxy.
Scaffold the Vite React TypeScript project and install frontend dependencies
Vite is a modern build tool that gives you instant hot module replacement and a fast dev server. You will use it to scaffold a TypeScript React project in seconds.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- Open the integrated terminal by pressing Ctrl+` (macOS and Windows).
- Move to your Desktop by running this command:
cd ~/Desktop
- Scaffold a new React + TypeScript project by running this command:
npm create vite@latest anime-watchlist -- --template react-ts
What might I see in the terminal?
Vite may ask interactive questions like whether to use a beta version or install dependencies now. If prompted, select No for both. You want to scaffold the project files only and install dependencies manually in the next step.
- Move into your new project folder and install the base dependencies by running these commands:
cd anime-watchlist
npm install
- Install the additional frontend packages your app needs by running:
npm install @reduxjs/toolkit react-redux tailwindcss @tailwindcss/vite
What are these packages?
Redux Toolkit provides RTK Query for data fetching with automatic caching and hook generation. react-redux connects your Redux store to React components.
Tailwind CSS is a utility-first CSS framework. The @tailwindcss/vite plugin integrates it directly into Vite's build pipeline with no config file needed.
- Confirm the packages installed by running:
ls node_modules/@reduxjs/toolkit
You should see a list of folders inside the toolkit package, confirming it installed correctly.
Seeing install errors?
Make sure you are inside the anime-watchlist folder before running npm install. If you see permission errors, try closing and reopening VS Code.
help me troubleshoot npm install errors for my Vite project dependencies
Next, you need to configure Tailwind CSS. The Vite template comes with a default src/index.css file full of sample styles that you will replace.
- Open src/index.css in the VS Code Explorer sidebar (click the file icon on the left, then expand src/).
- Delete all existing content in the file and replace it with this single line:
@import "tailwindcss";
Why just one line?
Tailwind CSS v4 uses a Vite plugin instead of a config file. This single import tells Tailwind to inject all its utility classes. The @tailwindcss/vite plugin handles the rest at build time.
- Save src/index.css.
Create the server directory and install backend dependencies
Your app needs a backend API to store watchlist items. You will create a separate server/ directory inside the project to keep backend code organized alongside the frontend.
- Create the server directory and move into it by running these commands:
mkdir server
cd server
- Initialize a new package.json for the server by running:
npm init -y
The server uses ES module syntax (import/export). You need to enable this in the server's package.json.
- Open server/package.json in VS Code.
- Find the line that says "main": "index.js", and add this line directly below it:
"type": "module",
- Save server/package.json.
- Install the backend packages by running:
npm install express mongoose cors
What are these packages?
Express is a minimal web framework for building REST APIs. Mongoose is an ODM (Object Data Modeling) library that connects your app to MongoDB Atlas.
cors handles Cross-Origin Resource Sharing headers so your frontend can communicate with the backend during development.
Now create the Express server file with a health check endpoint.
- Create a new file called index.js inside the server/ directory. Right-click the server folder in the Explorer sidebar, then click New File and name it index.js.
- Paste the following code into server/index.js:
import express from "express";
import cors from "cors";
const app = express();
const PORT = 3001;
app.use(cors());
app.use(express.json());
app.get("/api/health", (req, res) => {
res.json({ status: "ok" });
});
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
What does this code do?
- app.use(cors()) allows the frontend to make requests to this server without being blocked by browser security policies.
- app.use(express.json()) parses incoming JSON request bodies so your routes can read them.
- The /api/health route returns a simple JSON response to confirm the server is alive.
- The server listens on port 3001 to avoid conflicting with Vite's dev server on port 5173.
- Save server/index.js.
- Move back to the project root by running:
cd ..
The last piece of configuration connects the frontend and backend. Vite's dev server can proxy API requests to Express, so you never deal with CORS issues during development.
- Open vite.config.ts in the project root.
- Replace the entire file contents with the following:
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
proxy: {
"/api": {
target: "http://localhost:3001",
changeOrigin: true,
},
},
},
});
What does this configuration do?
- plugins: [react(), tailwindcss()] registers both the React and Tailwind CSS plugins for Vite's build pipeline.
- The server.proxy block tells Vite to forward any request starting with /api to your Express server at http://localhost:3001. This means your React code can call /api/watchlist without specifying the full backend URL.
- Save vite.config.ts.
Seeing red squiggles under the imports?
If VS Code shows errors under the @tailwindcss/vite import, make sure you ran the npm install commands from the project root (not inside server/). Check that @tailwindcss/vite appears in your root package.json under dependencies.
help me fix import errors in vite.config.ts for my Tailwind CSS Vite plugin
✔️ Awesome, I've got everything!
Great. Double check you have saved both vite.config.ts and src/index.css.
ⓧ I'd like to double check the full code
Here is what your vite.config.ts should look like:
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
proxy: {
"/api": {
target: "http://localhost:3001",
changeOrigin: true,
},
},
},
});
And your src/index.css should contain only:
@import "tailwindcss";
Verify the setup by running both servers
Your project has two processes that need to run simultaneously: the Express API server and the Vite dev server. You will start each one in a separate terminal.
- Make sure you are in the project root (anime-watchlist folder).
- Start the Express server by running:
node server/index.js
You should see the message Server running on http://localhost:3001 in the terminal.
- Open a second terminal in VS Code by clicking the + icon in the terminal panel (top-right of the terminal area).
- Start the Vite dev server by running:
npm run dev
Vite will print a local URL. Open http://localhost:5173 in your browser. You should see the default Vite + React page with a spinning logo.
- Test the backend health endpoint by opening a new browser tab and navigating to:
http://localhost:5173/api/health
You should see {"status":"ok"} in the browser. This confirms that Vite's proxy is correctly forwarding /api requests to your Express server.
Not seeing the health response?
Make sure your Express server is still running in the first terminal (look for the Server running on http://localhost:3001 message). If it crashed, check server/index.js for typos and restart it with node server/index.js.
If the Vite dev server starts but the proxy returns a 504 error, confirm your Express server is on port 3001 and that vite.config.ts has the proxy block targeting http://localhost:3001.
help me debug why my Vite proxy is not forwarding requests to my Express server
Your development environment is ready. Both servers are running and the proxy connects them seamlessly. Next up, you will build the Express backend with a Mongoose schema and full CRUD routes for your watchlist.
Build the Express Backend
Your frontend is scaffolded and ready to display data, but it needs a backend to store and manage the anime watchlist items your users will create.
In this step, you'll build a Node.js REST API with Express and Mongoose that persists watchlist data to MongoDB Atlas. This gives your React app a persistent backend it can communicate with through clean HTTP endpoints.
In this step, get ready to:
- Define a Mongoose schema for watchlist items.
- Build CRUD routes for creating, reading, updating, and deleting items.
- Test the endpoints to confirm they respond correctly.
Define the Mongoose schema and connect to MongoDB Atlas
Every piece of data your backend stores needs a defined shape. Mongoose uses schemas to describe what fields a document has, what types they are, and which values are valid.
- In VS Code, create a new folder called models inside your server directory.
- Create a new file called WatchlistItem.js inside server/models/ and add the following code:
import mongoose from "mongoose";
const watchlistItemSchema = new mongoose.Schema(
{
mal_id: { type: Number, required: true },
title: { type: String, required: true },
image_url: { type: String, required: true },
status: {
type: String,
enum: ["Watching", "Completed", "Plan to Watch", "Dropped"],
default: "Plan to Watch",
},
score: { type: Number, min: 0, max: 10, default: 0 },
episodes_watched: { type: Number, default: 0 },
total_episodes: { type: Number, default: 0 },
},
{ timestamps: { createdAt: "created_at", updatedAt: "updated_at" } }
);
export default mongoose.model("WatchlistItem", watchlistItemSchema);
What does this code do?
This file defines the data shape for every anime in your watchlist:
- mal_id stores the MyAnimeList ID so you can link back to the original anime.
- status uses an enum to restrict values to four valid categories.
- score is constrained between 0 and 10 using min and max validators.
- The timestamps option automatically adds created_at and updated_at fields to every document.
- Save server/models/WatchlistItem.js.
You won't see a change yet. The next chunk uses this model in the server entry point.
✔️ Awesome, I've got everything!
Great. Make sure you've saved the file before moving on.
ⓧ I'd like to double check the full code
import mongoose from "mongoose";
const watchlistItemSchema = new mongoose.Schema(
{
mal_id: { type: Number, required: true },
title: { type: String, required: true },
image_url: { type: String, required: true },
status: {
type: String,
enum: ["Watching", "Completed", "Plan to Watch", "Dropped"],
default: "Plan to Watch",
},
score: { type: Number, min: 0, max: 10, default: 0 },
episodes_watched: { type: Number, default: 0 },
total_episodes: { type: Number, default: 0 },
},
{ timestamps: { createdAt: "created_at", updatedAt: "updated_at" } }
);
export default mongoose.model("WatchlistItem", watchlistItemSchema);
Now you need the server entry point to connect to MongoDB Atlas and serve your API.
- Open server/index.js and replace its contents with the following code:
import express from "express";
import mongoose from "mongoose";
import cors from "cors";
import watchlistRouter from "./routes/watchlist.js";
const app = express();
const PORT = 3001;
app.use(cors());
app.use(express.json());
mongoose
.connect("your-mongodb-atlas-connection-string-here")
.then(() => console.log("Connected to MongoDB Atlas"))
.catch((err) => console.error("MongoDB connection error:", err));
app.get("/api/health", (req, res) => {
res.json({ status: "ok" });
});
app.use("/api/watchlist", watchlistRouter);
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
What does this code do?
- cors() allows cross-origin requests so the frontend can talk to the backend during development.
- express.json() parses incoming JSON request bodies so your route handlers can read req.body.
- mongoose.connect() opens the connection to your MongoDB Atlas cluster using your connection string.
- The /api/health endpoint is a simple check that confirms the server is running.
- The watchlistRouter import mounts your CRUD routes at /api/watchlist. You'll create this file next.
Where do I get my MongoDB Atlas connection string?
In your MongoDB Atlas dashboard, navigate to your cluster and click the Connect button. Select the Drivers connection method, then copy the connection string provided.
Replace the password placeholder in the string with your actual database user password. Also replace the database name at the end of the string with anime-watchlist or any name you prefer.
- Replace "your-mongodb-atlas-connection-string-here" in server/index.js with your actual MongoDB Atlas connection string.
- Save server/index.js.
Don't restart the server yet. The file imports ./routes/watchlist.js which you haven't created. You'll build that next and restart everything together.
Worried about the missing routes file?
It's fine that the server can't start right now. The import for watchlistRouter will resolve once you create the routes file in the next substep. You'll restart the server after both files exist.
If your IP address is not in your Atlas cluster's access list, the connection will also fail. Make sure your current IP is added under Network Access in your Atlas dashboard.
help me debug my MongoDB Atlas connection
✔️ Awesome, I've got everything!
Great. Make sure you've saved the file and replaced the placeholder connection string before moving on.
ⓧ I'd like to double check the full code
import express from "express";
import mongoose from "mongoose";
import cors from "cors";
import watchlistRouter from "./routes/watchlist.js";
const app = express();
const PORT = 3001;
app.use(cors());
app.use(express.json());
mongoose
.connect("your-mongodb-atlas-connection-string-here")
.then(() => console.log("Connected to MongoDB Atlas"))
.catch((err) => console.error("MongoDB connection error:", err));
app.get("/api/health", (req, res) => {
res.json({ status: "ok" });
});
app.use("/api/watchlist", watchlistRouter);
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
Remember to replace the placeholder string with your real MongoDB Atlas connection string.
Build the CRUD routes
Your backend needs four operations for watchlist management: list all items, add a new item, update an existing item, and delete an item. These map to the standard HTTP methods GET, POST, PUT, and DELETE.
- Create a new folder called routes inside your server directory.
- Create a new file called watchlist.js inside server/routes/ and add the following code for the read and create routes:
import { Router } from "express";
import WatchlistItem from "../models/WatchlistItem.js";
const router = Router();
// GET all watchlist items, sorted by newest first
router.get("/", async (req, res) => {
try {
const items = await WatchlistItem.find().sort({ created_at: -1 });
res.json(items);
} catch (error) {
res.status(500).json({ error: "Failed to fetch watchlist" });
}
});
// POST a new item to the watchlist
router.post("/", async (req, res) => {
try {
const item = new WatchlistItem(req.body);
const saved = await item.save();
res.status(201).json(saved);
} catch (error) {
res.status(400).json({ error: "Failed to add item" });
}
});
What does this code do?
- Router() creates a modular route handler that you can mount on a path in your main server file.
- The GET route uses WatchlistItem.find() to fetch all documents, sorted by created_at descending so the newest items appear first.
- The POST route creates a new WatchlistItem document from the request body and saves it to MongoDB.
- Both routes use try/catch to return proper error status codes if something goes wrong.
You won't see a change yet. Add the update and delete routes next to complete the file.
- Below the POST route in server/routes/watchlist.js, add the update and delete routes plus the export:
// PUT (update) a watchlist item by ID
router.put("/:id", async (req, res) => {
try {
const updated = await WatchlistItem.findByIdAndUpdate(req.params.id, req.body, {
new: true,
runValidators: true,
});
if (!updated) return res.status(404).json({ error: "Item not found" });
res.json(updated);
} catch (error) {
res.status(400).json({ error: "Failed to update item" });
}
});
// DELETE a watchlist item by ID
router.delete("/:id", async (req, res) => {
try {
const deleted = await WatchlistItem.findByIdAndDelete(req.params.id);
if (!deleted) return res.status(404).json({ error: "Item not found" });
res.status(204).send();
} catch (error) {
res.status(500).json({ error: "Failed to delete item" });
}
});
export default router;
What does this code do?
- The PUT route uses findByIdAndUpdate with { new: true } to return the updated document instead of the old one.
- runValidators: true ensures Mongoose still enforces schema rules (like the status enum) on updates.
- The DELETE route uses findByIdAndDelete and returns a 204 status (no content) on success.
- Both routes check if the item exists and return a 404 if the ID doesn't match any document.
- Save server/routes/watchlist.js.
Your routes file is complete. All four CRUD operations are ready.
✔️ Awesome, I've got everything!
Great. Double check you've saved the file before moving on.
ⓧ I'd like to double check the full code
import { Router } from "express";
import WatchlistItem from "../models/WatchlistItem.js";
const router = Router();
router.get("/", async (req, res) => {
try {
const items = await WatchlistItem.find().sort({ created_at: -1 });
res.json(items);
} catch (error) {
res.status(500).json({ error: "Failed to fetch watchlist" });
}
});
router.post("/", async (req, res) => {
try {
const item = new WatchlistItem(req.body);
const saved = await item.save();
res.status(201).json(saved);
} catch (error) {
res.status(400).json({ error: "Failed to add item" });
}
});
router.put("/:id", async (req, res) => {
try {
const updated = await WatchlistItem.findByIdAndUpdate(req.params.id, req.body, {
new: true,
runValidators: true,
});
if (!updated) return res.status(404).json({ error: "Item not found" });
res.json(updated);
} catch (error) {
res.status(400).json({ error: "Failed to update item" });
}
});
router.delete("/:id", async (req, res) => {
try {
const deleted = await WatchlistItem.findByIdAndDelete(req.params.id);
if (!deleted) return res.status(404).json({ error: "Item not found" });
res.status(204).send();
} catch (error) {
res.status(500).json({ error: "Failed to delete item" });
}
});
export default router;
Test the backend endpoints
All three server files are ready. Time to restart the server and confirm each endpoint works.
- In the terminal where your Express server is running, press Ctrl+C to stop it.
- Restart the server by running:
node server/index.js
- You should see Connected to MongoDB Atlas and Server running on http://localhost:3001 in your terminal.
Not seeing the connection message?
- Check that your connection string in server/index.js includes the correct password and database name.
- Make sure your IP address is allowed in your Atlas cluster's Network Access settings.
- If you see a module not found error for ./routes/watchlist.js, confirm the file exists at server/routes/watchlist.js and the filename matches exactly.
help me debug why my Express server won't start
Now test the endpoints. Open a second terminal to run these commands while the server stays running.
- Test the health endpoint by opening your browser and navigating to:
http://localhost:3001/api/health
- You should see {"status":"ok"} in your browser.
- Now test creating a watchlist item. In your second terminal, run:
curl -X POST http://localhost:3001/api/watchlist -H "Content-Type: application/json" -d '{"mal_id": 1, "title": "Cowboy Bebop", "image_url": "https://example.com/bebop.jpg", "status": "Watching", "score": 9, "episodes_watched": 5, "total_episodes": 26}'
- You should see a JSON response with the saved item, including an _id field and created_at timestamp.
- Verify the item was saved by running:
curl http://localhost:3001/api/watchlist
- You should see an array containing the Cowboy Bebop item you just created.
- Finally, delete the test item. Copy the _id value from the POST response and run:
curl -X DELETE http://localhost:3001/api/watchlist/your-item-id-here
- The response will be empty (204 status). Run the GET request again to confirm the array is now empty.
Getting an error from curl?
- If you see a connection refused error, make sure your server is still running in the first terminal.
- If the POST returns a 400 error, check that your JSON is valid. Missing commas or unquoted keys will cause parsing failures.
- On Windows, you may need to use double quotes for the curl command and escape the inner quotes. Alternatively, use your browser or a tool like Postman for POST requests.
help me troubleshoot my Express CRUD endpoint errors
Your Express backend is fully operational with all four CRUD endpoints responding correctly. Next up, you'll configure the Redux store and RTK Query API slices that connect your React frontend to these endpoints.
Configure Redux Store and RTK Query API Slices
Your Express backend is up and running with full CRUD endpoints. Now you need a way for your React frontend to talk to both your backend and the external Jikan anime API.
Instead of manually writing fetch calls and managing loading states yourself, you'll use RTK Query. It auto-generates React hooks for every endpoint, handles caching, and automatically refetches stale data when you invalidate a cache tag.
In this step, get ready to:
- Create the Jikan API slice with a search endpoint for querying anime.
- Create the Watchlist API slice with CRUD endpoints and tag-based cache invalidation.
- Configure the Redux store, wrap the app with Provider, and verify both slices load in Redux DevTools.
Create the Jikan API slice
RTK Query organizes data fetching into API slices. Each slice has its own base URL and set of endpoints. You'll create one slice for the Jikan API (external anime data) and another for your own backend (watchlist CRUD).
Before creating the API slices, you need TypeScript interfaces that describe the shape of data flowing through your app.
- Create a new file called src/types.ts and add the following type definitions:
export interface JikanAnime {
mal_id: number;
title: string;
images: {
jpg: {
image_url: string;
large_image_url: string;
};
};
score: number;
episodes: number;
synopsis: string;
status: string;
}
export interface JikanSearchResponse {
data: JikanAnime[];
pagination: {
last_visible_page: number;
has_next_page: boolean;
};
}
export interface WatchlistItem {
_id: string;
mal_id: number;
title: string;
image_url: string;
status: "Watching" | "Completed" | "Plan to Watch" | "Dropped";
score: number;
episodes_watched: number;
total_episodes: number;
created_at: string;
}
export type NewWatchlistItem = Omit<WatchlistItem, "_id" | "created_at">;
export type UpdateWatchlistItem = Partial<Omit<WatchlistItem, "_id" | "mal_id" | "created_at">> & { _id: string };
What does this code do?
These interfaces define the data shapes for your entire app.
- JikanAnime matches the shape of anime objects returned by the Jikan API.
- WatchlistItem matches what your MongoDB documents look like (including the _id field that Mongoose adds).
- NewWatchlistItem uses TypeScript's Omit utility to exclude server-generated fields when creating a new item.
- UpdateWatchlistItem makes all fields optional (via Partial) except _id, which is required to identify which item to update.
- Save src/types.ts.
Now create the Jikan API slice. This slice defines how your frontend searches for anime.
- Create a new folder src/services/ inside your src directory.
- Create a new file called src/services/animeApi.ts and add the following code:
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
import type { JikanSearchResponse } from "../types";
export const animeApi = createApi({
// Unique key for this API slice in the Redux store
reducerPath: "animeApi",
// Base URL for all requests in this slice
baseQuery: fetchBaseQuery({ baseUrl: "https://api.jikan.moe/v4/" }),
endpoints: (builder) => ({
// Query endpoint that searches anime by a search term
searchAnime: builder.query<JikanSearchResponse, string>({
query: (searchTerm) => `anime?q=${encodeURIComponent(searchTerm)}&limit=12&sfw=true`,
}),
}),
});
// RTK Query auto-generates a lazy hook for triggering searches on demand
export const { useLazySearchAnimeQuery } = animeApi;
What does this code do?
- createApi defines an entire API slice with its base URL and endpoints in one place.
- fetchBaseQuery is a lightweight wrapper around the browser's fetch that handles JSON parsing automatically.
- The searchAnime endpoint is a query (read-only). RTK Query generates useLazySearchAnimeQuery automatically, which lets you trigger the search on demand rather than on mount.
- Save src/services/animeApi.ts.
You won't see a visible change yet. The next chunks wire this slice into the store and then into a component.
Why two separate API slices?
The recommended RTK Query pattern is one API slice per base URL. Your app fetches from two completely different servers (Jikan at https://api.jikan.moe/v4/ and your Express backend at /api/), so each gets its own slice with its own caching and middleware.
Create the Watchlist API slice
The watchlist slice talks to your Express backend. Unlike the read-only Jikan slice, this one needs mutations (create, update, delete) and a way to keep the cached list fresh after each change.
- Create a new file called src/services/watchlistApi.ts and add the following code:
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
import type { WatchlistItem, NewWatchlistItem, UpdateWatchlistItem } from "../types";
export const watchlistApi = createApi({
reducerPath: "watchlistApi",
// Points to your Express backend via Vite's dev proxy
baseQuery: fetchBaseQuery({ baseUrl: "/api/" }),
// Define cache tags for automatic refetching
tagTypes: ["Watchlist"],
endpoints: (builder) => ({
// GET /api/watchlist - fetch all items
getWatchlist: builder.query<WatchlistItem[], void>({
query: () => "watchlist",
providesTags: ["Watchlist"],
}),
// POST /api/watchlist - add a new item
addToWatchlist: builder.mutation<WatchlistItem, NewWatchlistItem>({
query: (item) => ({
url: "watchlist",
method: "POST",
body: item,
}),
invalidatesTags: ["Watchlist"],
}),
// PUT /api/watchlist/:id - update an existing item
updateWatchlistItem: builder.mutation<WatchlistItem, UpdateWatchlistItem>({
query: ({ _id, ...patch }) => ({
url: `watchlist/${_id}`,
method: "PUT",
body: patch,
}),
invalidatesTags: ["Watchlist"],
}),
// DELETE /api/watchlist/:id - remove an item
removeFromWatchlist: builder.mutation<void, string>({
query: (id) => ({
url: `watchlist/${id}`,
method: "DELETE",
}),
invalidatesTags: ["Watchlist"],
}),
}),
});
// RTK Query auto-generates one hook per endpoint
export const {
useGetWatchlistQuery,
useAddToWatchlistMutation,
useUpdateWatchlistItemMutation,
useRemoveFromWatchlistMutation,
} = watchlistApi;
What does this code do?
- tagTypes: ["Watchlist"] registers a cache tag. The getWatchlist query provides this tag, meaning its cached result is associated with it.
- Each mutation invalidates the "Watchlist" tag. When a mutation completes, RTK Query automatically refetches any query that provides that tag.
- The updateWatchlistItem query uses destructuring ({ _id, ...patch }) to separate the ID (used in the URL) from the fields being updated (sent in the body).
- Save src/services/watchlistApi.ts.
You won't see a change yet. The next chunk wires both slices into the Redux store.
What is cache invalidation?
When you add, update, or delete a watchlist item, the cached list is "stale" because it no longer matches the database. RTK Query's tag system solves this automatically. A mutation that invalidates a tag triggers a refetch of any query providing that same tag. Your UI stays in sync with the database without you writing any manual refetch logic.
Configure the Redux store and wrap the app with Provider
The Redux store is the central place where all your app's state lives. You need to register both API slice reducers and their middleware so RTK Query can manage caching and refetching behind the scenes.
- Create a new file called src/store.ts and add the following code:
import { configureStore } from "@reduxjs/toolkit";
import { setupListeners } from "@reduxjs/toolkit/query";
import { animeApi } from "./services/animeApi";
import { watchlistApi } from "./services/watchlistApi";
export const store = configureStore({
reducer: {
// Each API slice gets its own reducer registered by its reducerPath
[animeApi.reducerPath]: animeApi.reducer,
[watchlistApi.reducerPath]: watchlistApi.reducer,
},
middleware: (getDefaultMiddleware) =>
// Add RTK Query middleware for caching, polling, and refetching
getDefaultMiddleware()
.concat(animeApi.middleware)
.concat(watchlistApi.middleware),
});
// Enables refetchOnFocus and refetchOnReconnect behaviors
setupListeners(store.dispatch);
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
What does this code do?
- configureStore creates the Redux store with both API reducers. The computed property [animeApi.reducerPath] ensures the key matches the reducerPath string you defined in each slice.
- The middleware handles cache lifetime, polling intervals, and request deduplication for both API slices.
- setupListeners enables automatic refetching when the browser tab regains focus or the network reconnects.
- RootState and AppDispatch are TypeScript helper types you can use in components later for type-safe selectors and dispatch calls.
- Save src/store.ts.
The store exists but isn't connected to your React component tree yet. The next chunk fixes that.
The Provider component from react-redux makes the Redux store available to every component in your app. You need to wrap your root App component with it.
- Open src/main.tsx and replace its entire contents with the following:
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { Provider } from "react-redux";
import { store } from "./store";
import App from "./App";
import "./index.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<Provider store={store}>
<App />
</Provider>
</StrictMode>
);
What does this code do?
- Provider store={store} passes your configured Redux store down through React's context. Any component in the tree can now call RTK Query hooks like useGetWatchlistQuery.
- The StrictMode wrapper is from the Vite scaffold. It helps catch common React bugs during development.
- Save src/main.tsx.
- Return to your browser at localhost:5173. The page should still load without errors (Vite hot-reloads automatically).
- Open your browser's DevTools (press F12 or Cmd+Option+I on macOS / Ctrl+Shift+I on Windows) and look for a Redux tab.
- In the Redux DevTools panel, you should see both animeApi and watchlistApi listed in the state tree.
Install Redux DevTools if you don't have it
If you don't see a Redux tab in DevTools, you need the Redux DevTools browser extension. Install it from the Chrome Web Store (for Chrome/Edge) or Mozilla Add-ons (for Firefox). Then reload the page.
Page shows an error or blank screen?
- Check the browser console for import errors. Make sure the folder structure is src/services/animeApi.ts and src/services/watchlistApi.ts (not nested deeper).
- Verify that src/store.ts imports from ./services/animeApi and ./services/watchlistApi (relative paths with no file extension).
- Confirm src/main.tsx imports store from ./store (not ./store.ts).
Help me debug my Redux store setup
✔️ Awesome, I've got everything!
Great. Double check you've saved all your files.
ⓧ I'd like to double check the full code
Here are all the files you created or modified in this step:
export interface JikanAnime {
mal_id: number;
title: string;
images: {
jpg: {
image_url: string;
large_image_url: string;
};
};
score: number;
episodes: number;
synopsis: string;
status: string;
}
export interface JikanSearchResponse {
data: JikanAnime[];
pagination: {
last_visible_page: number;
has_next_page: boolean;
};
}
export interface WatchlistItem {
_id: string;
mal_id: number;
title: string;
image_url: string;
status: "Watching" | "Completed" | "Plan to Watch" | "Dropped";
score: number;
episodes_watched: number;
total_episodes: number;
created_at: string;
}
export type NewWatchlistItem = Omit<WatchlistItem, "_id" | "created_at">;
export type UpdateWatchlistItem = Partial<Omit<WatchlistItem, "_id" | "mal_id" | "created_at">> & { _id: string };
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
import type { JikanSearchResponse } from "../types";
export const animeApi = createApi({
reducerPath: "animeApi",
baseQuery: fetchBaseQuery({ baseUrl: "https://api.jikan.moe/v4/" }),
endpoints: (builder) => ({
searchAnime: builder.query<JikanSearchResponse, string>({
query: (searchTerm) => `anime?q=${encodeURIComponent(searchTerm)}&limit=12&sfw=true`,
}),
}),
});
export const { useLazySearchAnimeQuery } = animeApi;
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
import type { WatchlistItem, NewWatchlistItem, UpdateWatchlistItem } from "../types";
export const watchlistApi = createApi({
reducerPath: "watchlistApi",
baseQuery: fetchBaseQuery({ baseUrl: "/api/" }),
tagTypes: ["Watchlist"],
endpoints: (builder) => ({
getWatchlist: builder.query<WatchlistItem[], void>({
query: () => "watchlist",
providesTags: ["Watchlist"],
}),
addToWatchlist: builder.mutation<WatchlistItem, NewWatchlistItem>({
query: (item) => ({
url: "watchlist",
method: "POST",
body: item,
}),
invalidatesTags: ["Watchlist"],
}),
updateWatchlistItem: builder.mutation<WatchlistItem, UpdateWatchlistItem>({
query: ({ _id, ...patch }) => ({
url: `watchlist/${_id}`,
method: "PUT",
body: patch,
}),
invalidatesTags: ["Watchlist"],
}),
removeFromWatchlist: builder.mutation<void, string>({
query: (id) => ({
url: `watchlist/${id}`,
method: "DELETE",
}),
invalidatesTags: ["Watchlist"],
}),
}),
});
export const {
useGetWatchlistQuery,
useAddToWatchlistMutation,
useUpdateWatchlistItemMutation,
useRemoveFromWatchlistMutation,
} = watchlistApi;
import { configureStore } from "@reduxjs/toolkit";
import { setupListeners } from "@reduxjs/toolkit/query";
import { animeApi } from "./services/animeApi";
import { watchlistApi } from "./services/watchlistApi";
export const store = configureStore({
reducer: {
[animeApi.reducerPath]: animeApi.reducer,
[watchlistApi.reducerPath]: watchlistApi.reducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware()
.concat(animeApi.middleware)
.concat(watchlistApi.middleware),
});
setupListeners(store.dispatch);
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { Provider } from "react-redux";
import { store } from "./store";
import App from "./App";
import "./index.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<Provider store={store}>
<App />
</Provider>
</StrictMode>
);
Your Redux store is fully configured with two API slices. Next up, you'll build the search UI that puts the Jikan API slice to work, letting users search for real anime and add them to their watchlist.
Build the Anime Search UI
Your backend is connected to MongoDB Atlas and your Redux store has two RTK Query API slices ready to fetch data.
Now it's time to build the frontend that actually uses them. In this step, you'll create a search component that queries the Jikan API for real anime data and lets users add titles to their watchlist with a single click.
In this step, get ready to:
- Build the AnimeSearch component with a debounced search input.
- Display search results in a responsive grid with cover art.
- Wire up the Add to Watchlist button to persist data to MongoDB.
Build the AnimeSearch component with debounced search
The search component needs to query an external API every time the user types. Firing a network request on every keystroke would overwhelm both the browser and the Jikan API's rate limit of 60 requests per minute.
A debounce pattern solves this by waiting 500ms after the user stops typing before sending the request.
- Create the src/components folder by running this command:
mkdir src/components
- Create a new file called AnimeSearch.tsx inside src/components and add the following code:
import { useState, useEffect } from "react";
import { useLazySearchAnimeQuery } from "../services/animeApi";
import { useAddToWatchlistMutation } from "../services/watchlistApi";
import type { JikanAnime } from "../types";
function AnimeSearch() {
const [searchTerm, setSearchTerm] = useState("");
const [trigger, { data, isLoading, error }] = useLazySearchAnimeQuery();
const [addToWatchlist] = useAddToWatchlistMutation();
useEffect(() => {
const timer = setTimeout(() => {
if (searchTerm.trim().length >= 3) {
trigger(searchTerm.trim());
}
}, 500);
return () => clearTimeout(timer);
}, [searchTerm, trigger]);
const handleAdd = (anime: JikanAnime) => {
addToWatchlist({
mal_id: anime.mal_id,
title: anime.title,
image_url: anime.images.jpg.image_url,
status: "Plan to Watch",
score: 0,
episodes_watched: 0,
total_episodes: anime.episodes || 0,
});
};
return (
<section>
<h2 className="text-xl font-semibold mb-4">Search Anime</h2>
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search for anime (min 3 characters)..."
className="w-full p-3 rounded-lg bg-gray-800 border border-gray-700 text-white placeholder-gray-500 focus:outline-none focus:border-purple-500"
/>
{isLoading && <p className="mt-4 text-gray-400">Searching...</p>}
{error && <p className="mt-4 text-red-400">Error searching anime. Please try again.</p>}
{data && data.data.length > 0 && (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 mt-4">
{data.data.map((anime) => (
<div
key={anime.mal_id}
className="bg-gray-800 rounded-lg overflow-hidden border border-gray-700"
>
<img
src={anime.images.jpg.image_url}
alt={anime.title}
className="w-full h-48 object-cover"
/>
<div className="p-3">
<h3 className="font-medium text-sm truncate">{anime.title}</h3>
<p className="text-xs text-gray-400 mt-1">
Score: {anime.score || "N/A"} | Episodes: {anime.episodes || "?"}
</p>
<button
onClick={() => handleAdd(anime)}
className="mt-2 w-full py-1.5 bg-purple-600 hover:bg-purple-700 rounded text-sm font-medium transition-colors"
>
Add to Watchlist
</button>
</div>
</div>
))}
</div>
)}
</section>
);
}
export default AnimeSearch;
What does this code do?
This component combines three RTK Query patterns in one place:
- useLazySearchAnimeQuery returns a trigger function instead of firing automatically. This gives you control over exactly when the API call happens.
- The useEffect sets a 500ms timer every time searchTerm changes. If the user types again within 500ms, the old timer is cleared and a new one starts. Only when the user pauses does the search fire.
- useAddToWatchlistMutation returns a mutation trigger that sends a POST request to your Express backend. Because it has invalidatesTags: ["Watchlist"], any cached watchlist data is automatically refetched after adding an item.
- Save src/components/AnimeSearch.tsx.
Seeing TypeScript errors?
- Check that src/types.ts exists with the JikanAnime interface from the previous step.
- Make sure src/services/animeApi.ts exports useLazySearchAnimeQuery and src/services/watchlistApi.ts exports useAddToWatchlistMutation.
Help me debug TypeScript errors in my AnimeSearch component
Display search results in a grid
The AnimeSearch component is ready, but it needs a page layout to render inside. You also need a placeholder for the Watchlist component you'll build in the next step.
- Create a new file called Watchlist.tsx inside src/components with this placeholder code:
function Watchlist() {
return (
<section>
<h2 className="text-xl font-semibold mb-4">My Watchlist</h2>
<p className="text-gray-500">Your watchlist is empty. Search and add some anime above!</p>
</section>
);
}
export default Watchlist;
Why a placeholder?
The App layout imports both AnimeSearch and Watchlist. Without this placeholder file, TypeScript would refuse to compile. You'll replace this with the full Watchlist component in the next step.
- Now replace the contents of src/App.tsx with the app layout:
import AnimeSearch from "./components/AnimeSearch";
import Watchlist from "./components/Watchlist";
function App() {
return (
<div className="min-h-screen bg-gray-900 text-white">
<header className="bg-gray-800 shadow-lg p-4">
<h1 className="text-2xl font-bold text-center text-purple-400">
Anime Watchlist
</h1>
</header>
<main className="max-w-6xl mx-auto p-4 space-y-8">
<AnimeSearch />
<Watchlist />
</main>
</div>
);
}
export default App;
What does this layout do?
- The outer div sets a full-height dark background with white text using Tailwind CSS utility classes.
- The header creates a sticky-looking top bar with the app title in purple.
- The main container centers content at a max width of 6xl and spaces the search and watchlist sections apart with space-y-8.
- Save both src/App.tsx and src/components/Watchlist.tsx.
- Switch to your browser at localhost:5173. You should see a dark page with a purple "Anime Watchlist" header, a search input, and a "My Watchlist" section below.
- Type Naruto into the search bar. After a brief pause, a grid of anime cards should appear with cover images, titles, scores, and episode counts.
Not seeing search results?
- Make sure you type at least 3 characters. The debounce only fires when searchTerm.trim().length >= 3.
- Open the browser's DevTools Network tab and look for a request to api.jikan.moe. If you see a CORS error, check that the Jikan API is reachable from your network.
- Confirm your Vite dev server is still running in the terminal. If it stopped, restart it with npm run dev.
Help me debug why anime search results aren't appearing
Wire up the Add to Watchlist button
Each anime card in your search results has an "Add to Watchlist" button. The handleAdd function maps the Jikan API response data into the shape your Express backend expects.
How does the data flow from Jikan to MongoDB?
When a user clicks "Add to Watchlist", this is what happens:
- handleAdd takes the JikanAnime object and extracts mal_id, title, and image_url from it.
- It sets default values: status as "Plan to Watch", score as 0, and episodes watched as 0.
- RTK Query sends a POST request to /api/watchlist. The Vite proxy forwards this to your Express server at localhost:3001.
- Express saves the item to MongoDB via Mongoose, and RTK Query's tag invalidation automatically refetches the watchlist cache.
- Search for Naruto in your app.
- Click Add to Watchlist on any anime card.
- Open a new browser tab and navigate to localhost:3001/api/watchlist. You should see a JSON array containing the anime you just added, with fields like mal_id, title, and status set to "Plan to Watch".
Item not saving to the database?
- Check your Express server terminal for errors. If you see a MongoDB connection error, verify your connection string in server/index.js.
- Open DevTools Network tab and look for the POST request to /api/watchlist. A 400 status means the request body is missing required fields.
- Make sure your Express server is running. If it stopped, restart it with node server/index.js.
Help me debug why Add to Watchlist isn't saving to MongoDB
✔️ Awesome, I've got everything!
Great. Make sure you have saved both src/components/AnimeSearch.tsx and src/App.tsx.
ⓧ I'd like to double check the full code
Here is the full src/components/AnimeSearch.tsx:
import { useState, useEffect } from "react";
import { useLazySearchAnimeQuery } from "../services/animeApi";
import { useAddToWatchlistMutation } from "../services/watchlistApi";
import type { JikanAnime } from "../types";
function AnimeSearch() {
const [searchTerm, setSearchTerm] = useState("");
const [trigger, { data, isLoading, error }] = useLazySearchAnimeQuery();
const [addToWatchlist] = useAddToWatchlistMutation();
useEffect(() => {
const timer = setTimeout(() => {
if (searchTerm.trim().length >= 3) {
trigger(searchTerm.trim());
}
}, 500);
return () => clearTimeout(timer);
}, [searchTerm, trigger]);
const handleAdd = (anime: JikanAnime) => {
addToWatchlist({
mal_id: anime.mal_id,
title: anime.title,
image_url: anime.images.jpg.image_url,
status: "Plan to Watch",
score: 0,
episodes_watched: 0,
total_episodes: anime.episodes || 0,
});
};
return (
<section>
<h2 className="text-xl font-semibold mb-4">Search Anime</h2>
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search for anime (min 3 characters)..."
className="w-full p-3 rounded-lg bg-gray-800 border border-gray-700 text-white placeholder-gray-500 focus:outline-none focus:border-purple-500"
/>
{isLoading && <p className="mt-4 text-gray-400">Searching...</p>}
{error && <p className="mt-4 text-red-400">Error searching anime. Please try again.</p>}
{data && data.data.length > 0 && (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 mt-4">
{data.data.map((anime) => (
<div
key={anime.mal_id}
className="bg-gray-800 rounded-lg overflow-hidden border border-gray-700"
>
<img
src={anime.images.jpg.image_url}
alt={anime.title}
className="w-full h-48 object-cover"
/>
<div className="p-3">
<h3 className="font-medium text-sm truncate">{anime.title}</h3>
<p className="text-xs text-gray-400 mt-1">
Score: {anime.score || "N/A"} | Episodes: {anime.episodes || "?"}
</p>
<button
onClick={() => handleAdd(anime)}
className="mt-2 w-full py-1.5 bg-purple-600 hover:bg-purple-700 rounded text-sm font-medium transition-colors"
>
Add to Watchlist
</button>
</div>
</div>
))}
</div>
)}
</section>
);
}
export default AnimeSearch;
Here is the full src/App.tsx:
import AnimeSearch from "./components/AnimeSearch";
import Watchlist from "./components/Watchlist";
function App() {
return (
<div className="min-h-screen bg-gray-900 text-white">
<header className="bg-gray-800 shadow-lg p-4">
<h1 className="text-2xl font-bold text-center text-purple-400">
Anime Watchlist
</h1>
</header>
<main className="max-w-6xl mx-auto p-4 space-y-8">
<AnimeSearch />
<Watchlist />
</main>
</div>
);
}
export default App;
Your search UI is pulling real anime data from the Jikan API and persisting selections to MongoDB. Next up, you'll build the Watchlist component to display saved items with status controls, ratings, and delete functionality.
Build the Watchlist Management UI
In the previous step, you built the search interface that pulls real anime data from the Jikan API and adds items to your watchlist. Now it is time to build the other half of the equation: displaying and managing those saved items.
A watchlist is only useful if you can track your progress. You need to update an anime's status as you watch it, rate it when you finish, and remove it when you lose interest. This step builds the full management UI that reads from your MongoDB database and writes changes back through your Express API.
In this step, get ready to:
- Build the Watchlist component that fetches and displays all saved anime items.
- Add status dropdowns and rating inputs that update items through RTK Query mutations.
- Add delete functionality and verify that tag invalidation keeps the UI in sync.
Build the Watchlist component to fetch and display items
The Watchlist component is the heart of your app. It fetches all saved anime from your backend, displays them with their current status and score, and provides controls for updating and deleting each item.
- Create a new file called Watchlist.tsx inside src/components/ and paste this code:
import {
useGetWatchlistQuery,
useUpdateWatchlistItemMutation,
useRemoveFromWatchlistMutation,
} from "../services/watchlistApi";
import type { WatchlistItem } from "../types";
const STATUS_OPTIONS = ["Watching", "Completed", "Plan to Watch", "Dropped"] as const;
function Watchlist() {
const { data: watchlist, isLoading, error } = useGetWatchlistQuery();
const [updateItem] = useUpdateWatchlistItemMutation();
const [removeItem] = useRemoveFromWatchlistMutation();
const handleStatusChange = (item: WatchlistItem, newStatus: string) => {
updateItem({ _id: item._id, status: newStatus as WatchlistItem["status"] });
};
const handleScoreChange = (item: WatchlistItem, newScore: number) => {
updateItem({ _id: item._id, score: newScore });
};
const handleDelete = (id: string) => {
removeItem(id);
};
if (isLoading) return <p className="text-gray-400">Loading watchlist...</p>;
if (error) return <p className="text-red-400">Error loading watchlist.</p>;
if (!watchlist || watchlist.length === 0) {
return (
<section>
<h2 className="text-xl font-semibold mb-4">My Watchlist</h2>
<p className="text-gray-500">Your watchlist is empty. Search and add some anime above!</p>
</section>
);
}
return (
<section>
<h2 className="text-xl font-semibold mb-4">My Watchlist ({watchlist.length})</h2>
<div className="space-y-3">
{watchlist.map((item) => (
<div
key={item._id}
className="flex items-center gap-4 bg-gray-800 rounded-lg p-3 border border-gray-700"
>
<img
src={item.image_url}
alt={item.title}
className="w-16 h-20 object-cover rounded"
/>
<div className="flex-1 min-w-0">
<h3 className="font-medium truncate">{item.title}</h3>
<div className="flex flex-wrap gap-3 mt-2 items-center">
<select
value={item.status}
onChange={(e) => handleStatusChange(item, e.target.value)}
className="bg-gray-700 border border-gray-600 rounded px-2 py-1 text-sm"
>
{STATUS_OPTIONS.map((status) => (
<option key={status} value={status}>
{status}
</option>
))}
</select>
<label className="flex items-center gap-1 text-sm">
<span className="text-gray-400">Score:</span>
<input
type="number"
min="0"
max="10"
value={item.score}
onChange={(e) => handleScoreChange(item, Number(e.target.value))}
className="w-14 bg-gray-700 border border-gray-600 rounded px-2 py-1 text-sm"
/>
</label>
</div>
</div>
<button
onClick={() => handleDelete(item._id)}
className="text-red-400 hover:text-red-300 p-2 transition-colors"
aria-label="Remove from watchlist"
>
X
</button>
</div>
))}
</div>
</section>
);
}
export default Watchlist;
What does this code do?
This component has three logical layers working together:
- Data fetching: useGetWatchlistQuery() automatically fires a GET request to your Express backend when the component mounts. RTK Query manages the loading, error, and success states for you.
- Mutation handlers: handleStatusChange and handleScoreChange both call updateItem with the item's _id plus the changed field. handleDelete calls removeItem with just the ID.
- Conditional rendering: The component returns early for loading, error, and empty states. The main return only runs when data is available, mapping each item into a row with its cover image, title, status dropdown, score input, and delete button.
- Save Watchlist.tsx.
- Return to your browser at localhost:5173. If you added anime in the previous step, you should see them listed below the search bar with their cover images, titles, and a status dropdown defaulting to Plan to Watch.
Don't see your watchlist items?
- Make sure your Express server is still running in its terminal window. If it crashed, restart it.
- Check that you added at least one anime from the search results in the previous step. The empty-state message confirms the component works even with no data.
- Verify the file is saved at src/components/Watchlist.tsx (not in the project root or another folder).
Help me debug why my Watchlist component isn't displaying items.
Test status and rating updates
The status dropdown and score input both trigger the updateWatchlistItem mutation. Each mutation sends a PUT request to your Express backend and then invalidates the Watchlist cache tag, which forces RTK Query to refetch the list automatically.
- In the browser, find one of your watchlist items and click its status dropdown.
- Change the status from Plan to Watch to Watching.
- Change the score input to a value between 1 and 10 (for example, 8).
- Refresh the page. Both the status and score should persist because they were saved to MongoDB through your Express API.
Why do changes persist without a save button?
Each onChange event immediately fires a mutation to your backend. There is no local-only state sitting between the user and the database. This pattern works well for small, frequent updates like status changes and ratings.
Status or score not persisting after refresh?
- Open your browser DevTools Network tab and look for PUT requests to /api/watchlist/<id>. If you don't see them, the onChange handler may not be wired correctly.
- If the PUT request returns a 400 error, check that your Express server has runValidators: true in the findByIdAndUpdate call and that the score is between 0 and 10.
Help me figure out why my watchlist updates aren't saving to the database.
Verify tag invalidation with delete
The delete button calls removeFromWatchlist, which sends a DELETE request to your backend. The key thing to watch is what happens after the request completes.
- Click the red X button on one of your watchlist items.
- Watch the list. The item disappears immediately without you refreshing the page.
How does tag invalidation work here?
In your watchlistApi.ts, the getWatchlist query provides the tag Watchlist. Every mutation (add, update, delete) invalidates that same tag. When RTK Query sees an invalidated tag, it automatically refetches any query that provided it.
This means you never manually refetch data after a change. The cache system handles synchronization for you.
- To confirm the delete reached your database, refresh the page. The deleted item should still be gone.
Item reappears after refresh?
- Check the Network tab for the DELETE request. If it returns a 404, the item's _id may not be passing correctly to the mutation.
- Verify your Express route uses findByIdAndDelete(req.params.id) and returns a 204 status on success.
Help me debug why deleting a watchlist item isn't working.
✔️ Awesome, I've got everything!
Great. Double check that you have saved src/components/Watchlist.tsx and your browser is showing the watchlist with working controls.
ⓧ I'd like to double check the full code
Here is the full src/components/Watchlist.tsx file:
import {
useGetWatchlistQuery,
useUpdateWatchlistItemMutation,
useRemoveFromWatchlistMutation,
} from "../services/watchlistApi";
import type { WatchlistItem } from "../types";
const STATUS_OPTIONS = ["Watching", "Completed", "Plan to Watch", "Dropped"] as const;
function Watchlist() {
const { data: watchlist, isLoading, error } = useGetWatchlistQuery();
const [updateItem] = useUpdateWatchlistItemMutation();
const [removeItem] = useRemoveFromWatchlistMutation();
const handleStatusChange = (item: WatchlistItem, newStatus: string) => {
updateItem({ _id: item._id, status: newStatus as WatchlistItem["status"] });
};
const handleScoreChange = (item: WatchlistItem, newScore: number) => {
updateItem({ _id: item._id, score: newScore });
};
const handleDelete = (id: string) => {
removeItem(id);
};
if (isLoading) return <p className="text-gray-400">Loading watchlist...</p>;
if (error) return <p className="text-red-400">Error loading watchlist.</p>;
if (!watchlist || watchlist.length === 0) {
return (
<section>
<h2 className="text-xl font-semibold mb-4">My Watchlist</h2>
<p className="text-gray-500">Your watchlist is empty. Search and add some anime above!</p>
</section>
);
}
return (
<section>
<h2 className="text-xl font-semibold mb-4">My Watchlist ({watchlist.length})</h2>
<div className="space-y-3">
{watchlist.map((item) => (
<div
key={item._id}
className="flex items-center gap-4 bg-gray-800 rounded-lg p-3 border border-gray-700"
>
<img
src={item.image_url}
alt={item.title}
className="w-16 h-20 object-cover rounded"
/>
<div className="flex-1 min-w-0">
<h3 className="font-medium truncate">{item.title}</h3>
<div className="flex flex-wrap gap-3 mt-2 items-center">
<select
value={item.status}
onChange={(e) => handleStatusChange(item, e.target.value)}
className="bg-gray-700 border border-gray-600 rounded px-2 py-1 text-sm"
>
{STATUS_OPTIONS.map((status) => (
<option key={status} value={status}>
{status}
</option>
))}
</select>
<label className="flex items-center gap-1 text-sm">
<span className="text-gray-400">Score:</span>
<input
type="number"
min="0"
max="10"
value={item.score}
onChange={(e) => handleScoreChange(item, Number(e.target.value))}
className="w-14 bg-gray-700 border border-gray-600 rounded px-2 py-1 text-sm"
/>
</label>
</div>
</div>
<button
onClick={() => handleDelete(item._id)}
className="text-red-400 hover:text-red-300 p-2 transition-colors"
aria-label="Remove from watchlist"
>
X
</button>
</div>
))}
</div>
</section>
);
}
export default Watchlist;
Your MERN stack anime watchlist is fully functional. You can search real anime, add them to your list, update their status and rating, and delete them. Every change flows through RTK Query mutations to your Express API and persists in MongoDB. Next up, you will take this further in the Secret Mission by adding filtering and sorting to your watchlist.
Secret mission
Add Filtering and Sorting
Your watchlist works, but finding a specific anime gets harder as it grows. Can you add client-side filtering by status and sorting by rating or title, all without making extra API calls?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally (frontend and backend) with data stored in MongoDB Atlas on the free tier. 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:
- MongoDB Atlas collection storing your watchlist items (free tier).
- Local project folder anime-watchlist on your Desktop.
Keep everything running
No action needed. Choose this if you plan to keep building on this project.
- Your MongoDB Atlas data persists on the free tier at no cost.
- The local Vite dev server and Express server stop automatically when you close the terminal windows.
- To restart later, open a terminal in the anime-watchlist folder and run npm run dev for the frontend and node server/index.js for the backend.
Pause - I'll come back to this later
Shut down running processes to free up system resources. Your files and data stay intact for when you return.
- In each terminal running a server, press Ctrl+C to stop the process.
- Your MongoDB Atlas collection remains stored at no cost on the free tier.
- Your local project files remain on your Desktop.
Delete - I don't want to use this again
Remove all project resources and start fresh. This is irreversible.
- Stop the running servers by pressing Ctrl+C in each terminal.
- Drop the watchlist collection from MongoDB Atlas by following these steps:
- Delete the local project folder by running this command on macOS/Linux:
cd ~/Desktop
rm -rf anime-watchlist
- On Windows, run this instead:
cd ~\Desktop
rmdir /s /q anime-watchlist
Nice Work!
Nice Work!
You just built a full MERN stack anime watchlist application from scratch. Your app searches real anime data, persists watchlist items to a cloud database, and manages all data fetching through Redux Toolkit's modern patterns.
You've learned how to:
- Configure RTK Query with two API slices, auto-generated hooks, and tag-based cache invalidation to manage external and internal data sources.
- Build a Node/Express REST API with full CRUD operations backed by Mongoose and MongoDB Atlas.
- Integrate the Jikan API for real anime search with cover art and metadata, using a debounced lazy query pattern.
- Secret Mission: Added client-side filtering and sorting with derived state from cached RTK Query data.
Ready to quiz yourself?