Let Redux fetch for you — define an API slice with endpoints, read data with auto-generated hooks, send writes with mutations, and refresh the screen automatically with cache tags.
Why: createApi is where you describe your backend once. baseQuery sets the shared start of every URL, and endpoints lists the operations. A "query" endpoint reads data; you give it a function returning the URL piece, and RTK Query generates a hook named use + Endpoint + Query. Note: builder.query<ReturnType, ArgType> types both what comes back and what you pass in.
// app/apiSlice.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
type User = { id: number; name: string }
export const api = createApi({
reducerPath: 'api', // the store section RTK Query manages itself
baseQuery: fetchBaseQuery({
baseUrl: 'https://jsonplaceholder.typicode.com/',
}),
endpoints: (builder) => ({
// <User, number> = returns a User, takes a number (the id) as its argument
getUser: builder.query<User, number>({
query: (id) => `users/${id}`, // appended to baseUrl
}),
}),
})
// RTK Query generated this hook from the endpoint name: getUser → useGetUserQuery
export const { useGetUserQuery } = apiWhy: the API slice plugs into the same store as everything else, with one extra step — its middleware, which powers the caching and refetching. You add the generated reducer under its reducerPath and append its middleware. Note: this is a one-time edit to the store file from the first lesson; the rest of your slices stay exactly as they were.
// app/store.ts
import { configureStore } from '@reduxjs/toolkit'
import counterReducer from './counterSlice'
import { api } from './apiSlice'
export const store = configureStore({
reducer: {
counter: counterReducer,
// Add the API slice under the reducerPath you named in createApi
[api.reducerPath]: api.reducer,
},
// The middleware enables caching, deduping, and automatic refetching
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(api.middleware),
})
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatchWhy: this is the payoff — one line replaces the entire thunk lesson. The generated useGetUserQuery hook fetches on render, caches the result, and hands you data, isLoading, and error ready to use. Call it in two components with the same argument and only ONE request goes out; the second reads the cache. Note: no useEffect, no dispatch, no status field to manage.
'use client'
import { useGetUserQuery } from '../apiSlice'
export default function UserCard() {
// Fetches user 1, caches it, and tracks loading/error for you
const { data, isLoading, error } = useGetUserQuery(1)
if (isLoading) return <p>Loading…</p>
if (error) return <p>Could not load the user.</p>
return <p>Loaded: {data?.name}</p>
}Why: writes (POST, PUT, DELETE) use a "mutation" endpoint, which gives you a hook returning a trigger function plus its loading state. The powerful part is cache tags: a query can "provide" a tag and a mutation can "invalidate" it — when the write succeeds, every query holding that tag refetches automatically, so the screen always matches the server with no manual refresh.
// In createApi's endpoints, alongside getUser:
//
// getUsers: builder.query<User[], void>({
// query: () => 'users',
// providesTags: ['Users'], // this data is tagged 'Users'
// }),
// addUser: builder.mutation<User, { name: string }>({
// query: (body) => ({ url: 'users', method: 'POST', body }),
// invalidatesTags: ['Users'], // changing it refetches 'Users'
// }),
//
// (also add tagTypes: ['Users'] near the top of createApi)
'use client'
import { useAddUserMutation } from '../apiSlice'
export default function AddUser() {
// A mutation hook returns [trigger, { state of the request }]
const [addUser, { isLoading }] = useAddUserMutation()
return (
<button
disabled={isLoading}
// The await write invalidates 'Users' → the list query refetches itself
onClick={() => addUser({ name: 'Ada' })}
>
{isLoading ? 'Adding…' : 'Add user'}
</button>
)
}