Content
Difficulty: Easy Concepts: useQuery, query keys, isPending/isError, automatic caching
Take the component below written with useEffect and useState and refactor it to use useQuery from TanStack Query.
Starting point - paste this into a new component file:
'use client'
import { useEffect, useState } from 'react'
type Post = { id: number; title: string; body: string }
export default function PostList() {
const [posts, setPosts] = useState<Post[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
fetch('<https://jsonplaceholder.typicode.com/posts?_limit=10>')
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
})
.then(data => {
setPosts(data)
setLoading(false)
})
.catch(err => {
setError(err.message)
setLoading(false)
})
}, [])
if (loading) return <p>Loading posts...</p>
if (error) return <p>Error: {error}</p>
return (
<ul>
{posts.map(post => (
<li key={post.id}>
<h2>{post.title}</h2>
<p>{post.body}</p>
</li>
))}
</ul>
)
}
Requirements:
npm install @tanstack/react-queryQueryClientProvider inside a Client Component (e.g. app/providers.tsx) and add it to your root layoutuseEffect + three useState calls with a single useQuery call['posts']posts, loading, and error state variables and their setters should no longer existisPending, error state must use isError['posts'] and verify in the Network tab that navigating between pages does not refetch when the data is still freshHints:
QueryClient must be created with useState(() => new QueryClient()) - not at module scope - so each user gets their own cacheuseQuery returns data as undefined while isPending is true, so TypeScript will narrow correctly if you return early on isPendingstaleTime: 1000 * 60 to your querytry/catch in queryFn - throwing inside it is how you signal an error to TanStack QueryDifficulty: Easy Concepts: useMutation, mutationFn, useQueryClient, invalidateQueries
Add a form that creates a new post and invalidates the cached list so the UI updates without a manual refetch.
Requirements:
CreatePostForm component with a single <input name="title"> and a submit buttonuseMutation with a mutationFn that POSTs to https://jsonplaceholder.typicode.com/posts with { title, body: 'placeholder', userId: 1 }queryClient.invalidateQueries({ queryKey: ['posts'] }) so the post list from Exercise 1 refetchesmutation.isPending is true and show 'Adding...' text during that timeHints:
const queryClient = useQueryClient() - you'll use it for the invalidationmutation.mutate(variables) triggers the mutation. Use onSuccess on the useMutation config to handle invalidation, not on the .mutate() call siteid but doesn't actually persist it, so your list won't grow on refetch. The invalidation still fires, and your Network tab will show the GET /posts request - that's what you're verifyingDifficulty: Medium Concepts: useMutation lifecycle (onMutate, onError, onSettled), cache snapshot, cancelQueries
Build a list of todos with a checkbox on each one. Toggling the checkbox should feel instant, and the UI must roll back cleanly if the server rejects the change.
Starting point - use this query as the data source:
type Todo = { id: number; userId: number; title: string; completed: boolean }
const { data: todos } = useQuery<Todo[]>({
queryKey: ['todos'],
queryFn: () =>
fetch('<https://jsonplaceholder.typicode.com/todos?_limit=8>').then(r => r.json()),
})
Requirements:
todo.completed and a label showing todo.titletoggleMutation that PATCHes https://jsonplaceholder.typicode.com/todos/${id} with the new completed valueonMutate:
queryClient.cancelQueries({ queryKey: ['todos'] }) so an in-flight refetch can't overwrite the optimistic updatequeryClient.getQueryData<Todo[]>(['todos'])queryClient.setQueryData{ previousTodos } as the context objectonError, restore previousTodos from the context object back into the cacheonSettled, invalidate ['todos'] regardless of outcomemutationFn with one that waits 800ms and then throws. The checkbox should tick on, then tick back off when the rollback firesHints:
onMutate is automatically passed as the third argument to onError - that's the only way to recover the previous state, so don't forget to return itcancelQueries, a refetch that started before the click can land after your optimistic update and silently revert the UI for a frame. It's a subtle bug - include the call from the startuseMutation<Todo, Error, Todo, { previousTodos: Todo[] | undefined }>completed. Since onSettled invalidates, the refetch will revert to the original server state. That's fine for the exercise: you're practicing the optimistic + rollback pattern, not building a real backendDifficulty: Easy Concepts: localStorage, JWT structure (mock), protected routes, useEffect for client-only APIs
Build a hardcoded login flow that stores a fake token in localStorage and gates a dashboard page behind it.
Requirements:
/login page with two fields (email, password) and a submit button[email protected] and the password is password123
'fake.jwt.token' under the key 'token' in localStorage and navigate to /dashboard/dashboard page that:
'token' from localStorage on mount/login/dashboard that removes the token and redirects to /loginuseRequireAuth hook in hooks/useRequireAuth.ts and use it from /dashboardHints:
localStorage only exists in the browser. Reading it at the top of a Client Component during the initial render will throw localStorage is not defined during server rendering - always read inside useEffect or guard with typeof window !== 'undefined'useRouter().replace() (not .push()) for the redirect so the protected page isn't in the browser historynull (or a small loading placeholder) until your auth hook has finished checking - otherwise the protected content flashes for a frame before the redirectlocalStorage saysThe HackYourFuture curriculum is licensed under CC BY-NC-SA 4.0 *https://hackyourfuture.net/*

Built with ❤️ by the HackYourFuture community · Thank you, contributors
Found a mistake or have a suggestion? Let us know in the feedback form.