Task Manager Dashboard with React

Build a multi-route React app with auth, route guards, and dark mode.

Introduction

30 Second Summary

Every interactive web app eventually needs the same core wiring: user login, page-to-page navigation, and state that updates instantly when you click a button. These patterns show up in dashboards, project trackers, and productivity tools everywhere.

In this project, you will build a Task Manager Dashboard with authentication, protected routes, and a dark mode toggle using React, TypeScript, and TanStack Router.

What You'll Build

By the end, you can log in, land on a Dashboard showing live task counts, flip to a Tasks page to add and complete items, and toggle the whole app between light and dark mode.

By the end of this project, you'll have:

  • A file-based routing setup where adding a new file automatically creates a new page, with a pathless layout route that blocks unauthenticated users from accessing protected views.
  • A global task state powered by useReducer and Context so that adding, completing, or deleting a task on the Tasks page instantly updates the statistics on the Dashboard.
  • A theme toggle that switches every component between light and dark mode and remembers your choice across browser refreshes.
  • Secret Mission: localStorage persistence for tasks and a live "time ago" display that ticks every second, with proper cleanup to prevent memory leaks.

Are there any prerequisites?

You should be comfortable with HTML, CSS, and JavaScript basics. This project also assumes you already have Node.js and pnpm installed on your machine.

Before We Start

Before We Start...

Before we dive in, let's lock in what you're about to build and why it matters.

Scaffold and Configure Tooling

Before you can build any React components, you need a working project with the right tooling wired up.

In this step you will scaffold a Vite project with React and TypeScript, install routing and styling dependencies, then configure ESLint, Prettier, and Tailwind CSS so every file you write from here on is linted, formatted, and styled automatically.

In this step, get ready to:
  • Scaffold a Vite + React + TypeScript project and install all dependencies.
  • Configure the Vite build pipeline with TanStack Router and Tailwind CSS plugins.
  • Set up ESLint, Prettier, and Tailwind dark mode support.
Scaffold the Vite project and install dependencies
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Scaffold a new Vite project with the React + TypeScript template by running:
pnpm create vite@latest task-manager -- --template react-ts

You should see output confirming the project was created in a task-manager folder.

What did this command do?

The pnpm create vite@latest command downloads the latest Vite scaffold tool and generates a starter project.

The --template react-ts flag tells Vite to use the React + TypeScript template, giving you a working React app with TypeScript compilation already configured.

  • Enter the project directory and install the runtime dependencies by running:
cd task-manager
pnpm add @tanstack/react-router @tanstack/react-router-devtools
  • Install the dev dependencies by running:
pnpm add -D @tanstack/router-plugin tailwindcss @tailwindcss/vite prettier eslint-config-prettier

You should see both install commands complete with a summary of packages added.

What are these packages?

  • @tanstack/react-router provides file-based routing with full TypeScript support.
  • @tanstack/react-router-devtools adds an in-browser panel for inspecting routes during development.
  • @tanstack/router-plugin is the Vite plugin that auto-generates the route tree from your file structure.
  • tailwindcss and @tailwindcss/vite provide utility-first CSS with a dedicated Vite plugin for fast rebuilds.
  • prettier and eslint-config-prettier handle code formatting and prevent ESLint from fighting with Prettier over style rules.

Install failed or peer warnings?

  • Make sure you ran cd task-manager before running the install commands.
  • Peer dependency warnings are safe to ignore. They do not prevent the packages from working.

Help me troubleshoot my pnpm install errors.

  • Remove the default scaffold files and create the project directory structure by running:
rm src/App.tsx src/App.css
rm -rf src/assets
mkdir -p src/routes/_authenticated src/components src/context src/types
  • Confirm the directory structure exists by running:
ls src

You should see components, context, routes, and types listed alongside the remaining scaffold files.

Don't see the expected folders?

  • Double-check you are inside the task-manager directory. Run pwd to verify your current location.
  • If rm src/App.tsx failed, the scaffold may have used a different filename. Run ls src first to see what exists.

Help me fix my project directory structure.

Configure Vite with TanStack Router and Tailwind CSS
  • Open vite.config.ts in Neovim and replace its entire contents with the following:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { tanstackRouter } from '@tanstack/router-plugin/vite'

export default defineConfig({
  plugins: [
    tanstackRouter({
      target: 'react',
      autoCodeSplitting: true,
    }),
    tailwindcss(),
    react(),
  ],
})

Why does plugin order matter?

The TanStack Router plugin must come before the React plugin. It scans your src/routes/ directory and generates a route tree file (src/routeTree.gen.ts) that the React plugin then compiles.

The autoCodeSplitting: true option tells the router to automatically split each route into its own bundle chunk for faster page loads.

  • Save vite.config.ts.
  • Verify the dev server starts by running:
pnpm dev

You should see output showing the local server URL. Press Ctrl+C to stop it before continuing.

Dev server won't start?

  • If you see a "Cannot find module" error, confirm the pnpm add -D command from earlier completed without errors.
  • Check that your vite.config.ts imports match the code above exactly. A typo in the import path will prevent Vite from loading the plugin.

Help me fix my Vite dev server startup error.

Set up ESLint, Prettier, and Tailwind dark mode

You need to configure ESLint to work alongside Prettier and tell it to ignore the auto-generated route tree file.

  • Open eslint.config.js in Neovim. If the scaffold did not create one, create a new file with that name in the project root.
  • Add the following import near the top of the file, after any existing imports:
import eslintConfigPrettier from 'eslint-config-prettier/flat'
  • Add eslintConfigPrettier as the last entry in the exported config array. This ensures Prettier rules override any conflicting ESLint style rules.
  • Also add { ignores: ['src/routeTree.gen.ts'] } to the config array. This tells ESLint to skip the auto-generated route tree file that TanStack Router creates.
  • Save eslint.config.js.

What is eslint-config-prettier?

ESLint and Prettier can conflict over formatting rules like semicolons and quotes. The eslint-config-prettier/flat config disables all ESLint rules that would conflict with Prettier, so each tool handles only its own job.

  • Create the Prettier config file .prettierrc in the project root with the following content:
{
  "semi": false,
  "singleQuote": true,
  "trailingComma": "all",
  "printWidth": 100,
  "tabWidth": 2
}
  • Create .prettierignore in the project root with the following content:
src/routeTree.gen.ts
dist
  • Save both files. Confirm they exist by running:
ls .prettierrc .prettierignore

You should see both filenames listed in the output.

Why ignore the route tree and dist?

The src/routeTree.gen.ts file is auto-generated by TanStack Router every time you add or rename a route file. Formatting or linting it would create noisy diffs that get overwritten immediately.

The dist directory contains production build output. Formatting compiled code is pointless.

  • Open src/index.css and replace its entire contents with:
@import "tailwindcss";
@variant dark (&:where(.dark, .dark *));

What does the @variant dark line do?

By default, Tailwind CSS v4 uses the prefers-color-scheme media query for dark mode. That means the browser's system setting controls it.

The @variant dark override tells Tailwind to use a dark class on the <html> element instead. This lets your app toggle dark mode with JavaScript rather than relying on the OS setting.

  • Save src/index.css.

Before you run the dev server, what do you expect to see? Think about whether the app has any route files defined yet.

  • Start the dev server to confirm everything compiles:
pnpm dev

The server starts without errors. You may see a blank page or a router warning in the browser since there are no route files yet. The key confirmation is that the terminal shows a local URL with no build errors.

Seeing build errors?

  • If you see an error about src/App.tsx, check that you deleted it earlier. The default src/main.tsx may still import it. Open src/main.tsx and remove the App import line and its usage for now.
  • If Tailwind throws an error, confirm src/index.css starts with @import "tailwindcss"; and not the old Vite boilerplate styles.

Help me fix the build errors when running pnpm dev.

✔️ Awesome, I've got everything!

Great. Press Ctrl+C to stop the dev server when you are ready to move on.

ⓧ I'd like to double check the full code

Verify these files in your project match exactly:

vite.config.ts:

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { tanstackRouter } from '@tanstack/router-plugin/vite'

export default defineConfig({
  plugins: [
    tanstackRouter({
      target: 'react',
      autoCodeSplitting: true,
    }),
    tailwindcss(),
    react(),
  ],
})

.prettierrc:

{
  "semi": false,
  "singleQuote": true,
  "trailingComma": "all",
  "printWidth": 100,
  "tabWidth": 2
}

.prettierignore:

src/routeTree.gen.ts
dist

src/index.css:

@import "tailwindcss";
@variant dark (&:where(.dark, .dark *));

Your tooling is locked in. Next up, you will build the authentication system and route guards that protect the app's pages.

Set Up Authentication and Route Guards

Your tooling is configured and the dev server runs cleanly. Now you need the first feature that makes an app feel real: a login gate.

Right now, any visitor could type any URL and see every page. You need an authentication layer that keeps pages private until the user proves who they are. In this step you will build that layer using React Context for global auth state and TanStack Router's beforeLoad guard for route-level protection.

In this step, get ready to:
  • Create an AuthContext that manages login state with a custom hook.
  • Set up TanStack Router with a root layout and auth-aware context.
  • Build a pathless layout route that guards protected pages and a login form with post-login redirect.
Create the AuthContext

The AuthContext will hold the current user, expose a login function, and broadcast auth state to every component that needs it. You will store this in a dedicated context file so any component can call useAuth() to check login status.

  • Create a new file src/context/AuthContext.tsx and add the imports, type definitions, and context creation:
import { createContext, useContext, useState, useCallback } from 'react'
import type { ReactNode } from 'react'

interface User {
  username: string
  email: string
}

export interface AuthContextType {
  user: User | null
  isAuthenticated: boolean
  login: (username: string, password: string) => Promise<void>
  logout: () => void
}

const AuthContext = createContext<AuthContextType | null>(null)

What does this code do?

The User interface defines the shape of a logged-in user. AuthContextType describes everything the rest of the app can do with auth: read the user, check if they are authenticated, log in, and log out.

Creating the context with null as the default means any component that calls useContext(AuthContext) outside the provider will get null. The custom hook you add next will catch that and throw a helpful error.

  • Below the context creation in the same file, add the AuthProvider component:
export function AuthProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState<User | null>(null)

  const login = useCallback(async (username: string, password: string) => {
    // Simulate a 500ms network delay
    await new Promise((resolve) => setTimeout(resolve, 500))

    if (username === 'admin' && password === 'admin') {
      setUser({ username: 'admin', email: 'admin@taskmanager.dev' })
    } else {
      throw new Error('Invalid credentials')
    }
  }, [])

  const logout = useCallback(() => {
    setUser(null)
  }, [])

  return (
    <AuthContext.Provider value={{ user, isAuthenticated: !!user, login, logout }}>
      {children}
    </AuthContext.Provider>
  )
}

What does AuthProvider do?

AuthProvider wraps the app and provides auth state to all children. The login function is a mock that accepts admin/admin and simulates a 500ms network call. In production you would swap this for a real API request.

The useCallback wrapper ensures the login and logout functions keep a stable reference between renders, preventing unnecessary re-renders in child components.

  • Below the AuthProvider, add the useAuth custom hook that gives any child component type-safe access to auth state:
export function useAuth() {
  const context = useContext(AuthContext)
  if (!context) {
    throw new Error('useAuth must be used within an AuthProvider')
  }
  return context
}

Why throw an error in the hook?

If someone accidentally renders a component outside the AuthProvider, the hook throws a clear error message instead of returning null. This pattern makes debugging painless because you immediately know what went wrong.

  • Save src/context/AuthContext.tsx.

The root layout you create next imports from a ThemeContext that does not exist yet. You will build the real ThemeContext in a later step, but the app needs a placeholder now so it compiles.

  • Create src/context/ThemeContext.tsx with this temporary placeholder:
import type { ReactNode } from 'react'

// Placeholder - replaced with full implementation in Step 4
export function ThemeProvider({ children }: { children: ReactNode }) {
  return <>{children}</>
}

export function useTheme() {
  return { theme: 'light' as const, toggleTheme: () => {} }
}
  • Save src/context/ThemeContext.tsx.
Set up the router with auth context and root layout

TanStack Router uses a root route file as the outermost shell of your app. Every page renders inside this shell. You will create it with createRootRouteWithContext so the router can pass auth state down to every route's beforeLoad guard.

  • Create src/routes/__root.tsx and add the imports, context interface, route export, and RootLayout wrapper:
import { createRootRouteWithContext, Link, Outlet } from '@tanstack/react-router'
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'
import { AuthProvider, useAuth } from '../context/AuthContext'
import { ThemeProvider, useTheme } from '../context/ThemeContext'
import type { AuthContextType } from '../context/AuthContext'

export interface RouterContext {
  auth: AuthContextType
}

export const Route = createRootRouteWithContext<RouterContext>()({
  component: RootLayout,
})

function RootLayout() {
  return (
    <AuthProvider>
      <ThemeProvider>
        <InnerLayout />
      </ThemeProvider>
    </AuthProvider>
  )
}

What does this code do?

The RouterContext interface tells TanStack Router that a typed auth object is available to every route. createRootRouteWithContext<RouterContext>() is a double-invocation pattern. The first call sets the generic type, and the second receives route options.

RootLayout wraps the entire route tree in the providers (auth and theme) so every child route has access to both contexts.

  • Still in the same file, add the InnerLayout function that renders the nav bar with conditional links:
function InnerLayout() {
  const auth = useAuth()
  const { theme, toggleTheme } = useTheme()

  return (
    <div className="min-h-screen bg-gray-50 dark:bg-gray-900">
      <nav className="border-b border-gray-200 bg-white px-6 py-4 dark:border-gray-700 dark:bg-gray-800">
        <div className="mx-auto flex max-w-4xl items-center gap-6">
          <span className="text-lg font-bold text-gray-900 dark:text-white">Task Manager</span>
          {auth.isAuthenticated && (
            <>
              <Link to="/" className="text-gray-600 hover:text-blue-600 dark:text-gray-300 dark:hover:text-blue-400 [&.active]:font-bold [&.active]:text-blue-600">
                Dashboard
              </Link>
              <Link to="/tasks" className="text-gray-600 hover:text-blue-600 dark:text-gray-300 dark:hover:text-blue-400 [&.active]:font-bold [&.active]:text-blue-600">
                Tasks
              </Link>
            </>
          )}
  • Directly below (still inside the <nav>), add the right-side controls and close out the component:
          <div className="ml-auto flex items-center gap-3">
            <button
              onClick={toggleTheme}
              className="rounded-full bg-gray-100 px-3 py-1 text-sm dark:bg-gray-700 dark:text-gray-200"
            >
              {theme === 'light' ? 'Dark' : 'Light'}
            </button>
            {auth.isAuthenticated && (
              <button
                onClick={auth.logout}
                className="rounded bg-red-100 px-3 py-1 text-sm text-red-700 hover:bg-red-200 dark:bg-red-900 dark:text-red-200"
              >
                Logout
              </button>
            )}
          </div>
        </div>
      </nav>
      <main className="mx-auto max-w-4xl p-6">
        <Outlet />
      </main>
      <TanStackRouterDevtools position="bottom-right" />
    </div>
  )
}

What does InnerLayout render?

The nav shows the app title on the left. When the user is authenticated, Dashboard and Tasks links appear. The [&.active] selector is TanStack Router's built-in active-link class that automatically bolds the link matching the current route.

On the right side, a theme toggle button and a logout button (only visible when authenticated) round out the nav. <Outlet /> is where child routes render their content. TanStackRouterDevtools gives you a floating panel in development to inspect route state.

  • Save src/routes/__root.tsx.

Now you need to update the app's entry point so it creates the router with auth context and renders via the root route.

  • Replace the entire contents of src/main.tsx with the router setup and module declaration:
import { StrictMode } from 'react'
import ReactDOM from 'react-dom/client'
import { RouterProvider, createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
import { useAuth } from './context/AuthContext'
import { AuthProvider } from './context/AuthContext'
import './index.css'

const router = createRouter({
  routeTree,
  context: {
    auth: undefined!,
  },
})

declare module '@tanstack/react-router' {
  interface Register {
    router: typeof router
  }
}

What does this code do?

The routeTree import is auto-generated by the TanStack Router plugin every time you run the dev server. The context: { auth: undefined! } placeholder tells TypeScript the shape of the context. At runtime, the real auth object replaces it.

The declare module block registers the router type globally so hooks like useNavigate and useSearch know your routes and their params at compile time.

  • Below the module declaration in the same file, add the App component and the render call:
function App() {
  const auth = useAuth()
  return <RouterProvider router={router} context={{ auth }} />
}

const rootElement = document.getElementById('root')!
if (!rootElement.innerHTML) {
  const root = ReactDOM.createRoot(rootElement)
  root.render(
    <StrictMode>
      <AuthProvider>
        <App />
      </AuthProvider>
    </StrictMode>,
  )
}

Why is AuthProvider in two places?

The outer AuthProvider in main.tsx lets the App component call useAuth() and pass the live auth state into the router context. The inner AuthProvider in __root.tsx re-wraps the route tree so components inside routes can also call useAuth(). This two-layer pattern is the recommended approach from TanStack Router's authenticated routes guide.

  • Save src/main.tsx.
  • Switch to the terminal where pnpm dev is running. You should see the router plugin log a route-tree generation message.
  • Open your browser to the URL shown in the terminal (usually http://localhost:5173).

You should see a nav bar with "Task Manager" on the left and a "Dark" button on the right. No links or content appear below because no child routes exist yet.

Seeing a compile error about routeTree.gen?

If the file has not been generated yet, stop the dev server with Ctrl+C and restart it with pnpm dev. The router plugin generates the route tree on startup. If it still fails, confirm src/routes/__root.tsx exists (the plugin needs at least one route file).

Help me debug this

Create the auth guard and login route

The auth guard is a pathless layout route. Files prefixed with _ in TanStack Router create layout routes that do not add a segment to the URL. Their beforeLoad runs before any child route loads, making them perfect middleware for access control.

The guard also imports a TaskProvider that does not exist yet. You will build the real TaskContext in a later step, but you need a placeholder now just like you did for ThemeContext.

  • Create src/context/TaskContext.tsx with this temporary placeholder:
import type { ReactNode } from 'react'

// Placeholder - replaced with full implementation in Step 4
export function TaskProvider({ children }: { children: ReactNode }) {
  return <>{children}</>
}

export function useTaskContext() {
  return { tasks: [] as never[], dispatch: (() => {}) as never }
}
  • Save src/context/TaskContext.tsx.
  • Create src/routes/_authenticated.tsx with the guard logic:
import { createFileRoute, redirect, Outlet } from '@tanstack/react-router'
import { TaskProvider } from '../context/TaskContext'

export const Route = createFileRoute('/_authenticated')({
  beforeLoad: ({ context, location }) => {
    if (!context.auth.isAuthenticated) {
      throw redirect({
        to: '/login',
        search: {
          redirect: location.href,
        },
      })
    }
  },
  component: AuthenticatedLayout,
})

function AuthenticatedLayout() {
  return (
    <TaskProvider>
      <Outlet />
    </TaskProvider>
  )
}

What does this code do?

The beforeLoad hook fires before any child route renders. If the user is not authenticated, it throws a redirect to /login and stores the original URL in the redirect search param so you can send them back after login.

AuthenticatedLayout wraps child routes in TaskProvider so only authenticated views have access to task state. <Outlet /> renders whichever child route matched the URL.

  • Save src/routes/_authenticated.tsx.
  • Now create the login route. Add src/routes/login.tsx with the route configuration and LoginPage state:
import { createFileRoute, redirect } from '@tanstack/react-router'
import { useState } from 'react'
import { useAuth } from '../context/AuthContext'

export const Route = createFileRoute('/login')({
  validateSearch: (search) => ({
    redirect: (search.redirect as string) || '/',
  }),
  beforeLoad: ({ context, search }) => {
    if (context.auth.isAuthenticated) {
      throw redirect({ to: search.redirect })
    }
  },
  component: LoginPage,
})

function LoginPage() {
  const auth = useAuth()
  const { redirect: redirectTo } = Route.useSearch()
  const navigate = Route.useNavigate()
  const [username, setUsername] = useState('')
  const [password, setPassword] = useState('')
  const [error, setError] = useState('')
  const [isLoading, setIsLoading] = useState(false)

What does the route configuration do?

The validateSearch function parses the URL search params and extracts the redirect value (defaulting to /). The beforeLoad here does the opposite of the guard: if the user is already authenticated, it bounces them away from login so they cannot see the form while signed in.

Route.useSearch() and Route.useNavigate() are type-safe hooks scoped to this specific route. They know the exact shape of the search params from validateSearch.

  • Below the state declarations in the same file, add the submit handler and the first part of the return statement:
  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault()
    setError('')
    setIsLoading(true)

    try {
      await auth.login(username, password)
      navigate({ to: redirectTo })
    } catch {
      setError('Invalid username or password. Try admin/admin.')
    } finally {
      setIsLoading(false)
    }
  }

  return (
    <div className="flex min-h-[60vh] items-center justify-center">
      <form onSubmit={handleSubmit} className="w-full max-w-sm rounded-lg border border-gray-200 p-6 dark:border-gray-700 dark:bg-gray-800">
        <h1 className="mb-4 text-2xl font-bold dark:text-white">Sign In</h1>
        {error && (
          <p className="mb-3 rounded bg-red-100 px-3 py-2 text-sm text-red-700 dark:bg-red-900 dark:text-red-200">
            {error}
          </p>
        )}
        <div className="space-y-3">
  • Complete the form by adding the input fields, submit button, and closing tags directly below:
          <input
            type="text"
            placeholder="Username"
            value={username}
            onChange={(e) => setUsername(e.target.value)}
            className="w-full rounded border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white"
          />
          <input
            type="password"
            placeholder="Password"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
            className="w-full rounded border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white"
          />
          <button
            type="submit"
            disabled={isLoading}
            className="w-full rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
          >
            {isLoading ? 'Signing in...' : 'Sign In'}
          </button>
        </div>
      </form>
    </div>
  )
}

What does the login form do?

Four pieces of local state drive the form: username, password, an error message, and a loading flag. On submit, the handler calls auth.login() from the context. If login succeeds, navigate({ to: redirectTo }) sends the user to the page they originally requested. If it fails, a friendly error appears.

  • Save src/routes/login.tsx.

The last pieces are the child routes that live under the guard. Any route file inside the src/routes/_authenticated/ directory automatically inherits the beforeLoad check.

  • Create src/routes/_authenticated/index.tsx as a placeholder Dashboard route:
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/_authenticated/')({
  component: Dashboard,
})

function Dashboard() {
  return (
    <div>
      <h1 className="text-2xl font-bold text-gray-900 dark:text-white">Dashboard</h1>
      <p className="mt-2 text-gray-600 dark:text-gray-400">Welcome to the Task Manager.</p>
    </div>
  )
}
  • Create src/routes/_authenticated/tasks.tsx as a placeholder Tasks route:
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/_authenticated/tasks')({
  component: Tasks,
})

function Tasks() {
  return (
    <div>
      <h1 className="text-2xl font-bold text-gray-900 dark:text-white">Tasks</h1>
      <p className="mt-2 text-gray-600 dark:text-gray-400">Task list coming soon.</p>
    </div>
  )
}
  • Save both files.

Before you test, what do you think will happen when you navigate to / in the browser? Will you see the Dashboard or the login form?

  • Switch to your browser and refresh the page at http://localhost:5173.

You should see the Sign In form. The URL changes to /login?redirect=%2F because the guard caught your unauthenticated request and redirected you.

  • Type admin in the Username field and admin in the Password field.
  • Click Sign In.

After a brief loading state, you land on the Dashboard page. The nav bar now shows Dashboard and Tasks links, plus a Logout button on the right.

  • Click Tasks in the nav. You should see the placeholder Tasks page.
  • Click Logout. You should be redirected back to the Sign In form, and the nav links disappear.

Not being redirected to login?

Make sure src/routes/_authenticated.tsx starts with an underscore (_authenticated), not a plain letter. The underscore is what makes it a pathless layout route that acts as middleware.

Also confirm your child routes are inside the src/routes/_authenticated/ directory (not src/routes/ directly). The router plugin uses the folder name to associate children with the layout.

Help me debug my route guards

✔️ Awesome, I've got everything!

Great. Double check you have saved all files in this step.

ⓧ I'd like to double check the full code

Scroll up and confirm each of these code chunks is present in order:

  • src/context/AuthContext.tsx - imports and interfaces, then AuthProvider, then useAuth.
  • src/context/ThemeContext.tsx - placeholder ThemeProvider and useTheme.
  • src/routes/__root.tsx - imports through RootLayout, then InnerLayout nav links, then right-side controls.
  • src/main.tsx - router setup and module declaration, then App component and render.
  • src/context/TaskContext.tsx - placeholder TaskProvider and useTaskContext.
  • src/routes/_authenticated.tsx - beforeLoad guard and AuthenticatedLayout.
  • src/routes/login.tsx - route config and state, then handler and form opening, then inputs and closing.
  • src/routes/_authenticated/index.tsx - placeholder Dashboard.
  • src/routes/_authenticated/tasks.tsx - placeholder Tasks.

Authentication is live. Every route under the _authenticated folder is now locked behind a login gate. Next up, you will build the actual task components that live on those protected pages.

Build Task Components with Props and State

Your authentication and routing are solid. Users can log in, access protected routes, and get redirected if they try to sneak in without credentials.

In this step, you'll build the visual layer of the Task Manager. You'll define a TypeScript interface for task data, then create two reusable components: a TaskCard that displays individual tasks with typed props and callback patterns, and a TaskList that adds local filtering via useState.

In this step, get ready to:
  • Define the Task type with a TypeScript interface and status union.
  • Build a TaskCard component with typed props and callback patterns.
  • Build a TaskList component with local filter state and derived computed values.
Define the Task type

Every task in your app needs a consistent shape. TypeScript interfaces let you describe that shape once and enforce it everywhere a task object appears.

  • Create a new file at src/types/task.ts and add the following code:
export type TaskStatus = 'pending' | 'in-progress' | 'completed'

export interface Task {
  id: string
  title: string
  description: string
  status: TaskStatus
  createdAt: number
}

What does this code do?

  • TaskStatus is a union type that restricts status values to exactly three strings. Any typo or invalid status triggers a compile error.
  • Task is an interface describing every field a task must have. The createdAt field stores a numeric timestamp (milliseconds since epoch) so you can sort or display relative times later.
  • Save src/types/task.ts.
  • Check your terminal where pnpm dev is running. You should see no new errors related to this file.
Build the TaskCard component

Each task needs a visual representation. The TaskCard component receives a task object and two callback functions as props, keeping it completely reusable and decoupled from how state is managed.

  • Create a new file at src/components/TaskCard.tsx and add the imports, interface, and status style map:
import type { Task } from '../types/task'

interface TaskCardProps {
  task: Task
  onToggle: (id: string) => void
  onDelete: (id: string) => void
}

const statusStyles: Record<Task['status'], string> = {
  pending: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
  'in-progress': 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
  completed: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
}

What does this code do?

  • TaskCardProps defines the exact props this component accepts. The onToggle and onDelete callbacks let the parent control what happens when the user clicks a button.
  • statusStyles maps each task status to Tailwind classes for a colored badge. Using Record<Task['status'], string> ensures TypeScript will error if you miss a status.
  • Below the statusStyles map in the same file, add the component function:
export function TaskCard({ task, onToggle, onDelete }: TaskCardProps) {
  return (
    <div className="rounded-lg border border-gray-200 p-4 shadow-sm dark:border-gray-700 dark:bg-gray-800">
      <div className="flex items-start justify-between">
        <div>
          <h3 className="font-semibold text-gray-900 dark:text-white">{task.title}</h3>
          <p className="mt-1 text-sm text-gray-600 dark:text-gray-400">{task.description}</p>
        </div>
        <span className={`rounded-full px-2 py-1 text-xs font-medium ${statusStyles[task.status]}`}>
          {task.status}
        </span>
      </div>
      <div className="mt-3 flex gap-2">
        <button
          onClick={() => onToggle(task.id)}
          className="rounded bg-gray-100 px-3 py-1 text-sm hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
        >
          {task.status === 'completed' ? 'Reopen' : 'Complete'}
        </button>
        <button
          onClick={() => onDelete(task.id)}
          className="rounded bg-red-100 px-3 py-1 text-sm text-red-700 hover:bg-red-200 dark:bg-red-900 dark:text-red-200 dark:hover:bg-red-800"
        >
          Delete
        </button>
      </div>
    </div>
  )
}

What does this code do?

  • The component destructures its typed props and renders a card with the task title, description, and a status badge using the statusStyles map.
  • The toggle button label changes based on the current status. If a task is completed, clicking it shows "Reopen". Otherwise, it shows "Complete".
  • Both buttons call their respective callback props with the task's id. The parent component decides what actually happens to the data.
  • Save src/components/TaskCard.tsx.
  • Check your terminal. You should see no compile errors from the new file.

Seeing a type error?

  • Make sure the import path is '../types/task' (one level up from components/ to types/).
  • Confirm you exported both TaskStatus and Task from src/types/task.ts using the export keyword.

Why isn't my TaskCard compiling?

Build the TaskList component and verify

TaskList wraps multiple TaskCards and adds a filter bar. It uses useState for local filter state and derives the visible tasks from that state without needing any extra hooks.

  • Create a new file at src/components/TaskList.tsx and add the imports, types, filter config, and component logic:
import { useState } from 'react'
import type { Task, TaskStatus } from '../types/task'
import { TaskCard } from './TaskCard'

interface TaskListProps {
  tasks: Task[]
  onToggle: (id: string) => void
  onDelete: (id: string) => void
}

type FilterValue = TaskStatus | 'all'

const filterOptions: { label: string; value: FilterValue }[] = [
  { label: 'All', value: 'all' },
  { label: 'Pending', value: 'pending' },
  { label: 'In Progress', value: 'in-progress' },
  { label: 'Completed', value: 'completed' },
]

export function TaskList({ tasks, onToggle, onDelete }: TaskListProps) {
  // Local filter state - only this component cares about it
  const [filter, setFilter] = useState<FilterValue>('all')

  // Derived value - recomputed on every render, no useEffect needed
  const filteredTasks = filter === 'all' ? tasks : tasks.filter((task) => task.status === filter)

What does this code do?

  • FilterValue is a union of all task statuses plus 'all'. This gives you a type-safe set of filter options.
  • filterOptions is a constant array that drives the filter button UI. Adding a new status later means updating the type and the array.
  • filteredTasks is a derived value, not stored in state. Every time the component re-renders, it recomputes the filtered list from the current tasks and filter. No useEffect needed.
  • Directly below the filteredTasks line in the same file, add the component's return statement:
  return (
    <div>
      <div className="mb-4 flex gap-2">
        {filterOptions.map((option) => (
          <button
            key={option.value}
            onClick={() => setFilter(option.value)}
            className={`rounded-full px-3 py-1 text-sm ${
              filter === option.value
                ? 'bg-blue-600 text-white'
                : 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'
            }`}
          >
            {option.label}
          </button>
        ))}
      </div>
      <div className="space-y-3">
        {filteredTasks.length === 0 ? (
          <p className="text-center text-gray-500 dark:text-gray-400">No tasks match this filter.</p>
        ) : (
          filteredTasks.map((task) => (
            <TaskCard key={task.id} task={task} onToggle={onToggle} onDelete={onDelete} />
          ))
        )}
      </div>
    </div>
  )
}

What does this code do?

  • The filter buttons use conditional Tailwind classes. The active button gets a blue highlight while others stay gray.
  • When filteredTasks is empty, the component shows a "no tasks" message instead of rendering nothing.
  • Each TaskCard receives the parent's onToggle and onDelete callbacks directly. The key={task.id} prop helps React efficiently update the list.
  • Save src/components/TaskList.tsx.
  • Confirm no compile errors appear in your terminal.

Now you need a route to render these components in the browser. You'll create a temporary tasks route with hardcoded sample data to verify everything works.

  • Create a new file at src/routes/_authenticated/tasks.tsx and add the following code:
import { createFileRoute } from '@tanstack/react-router'
import { TaskList } from '../../components/TaskList'
import type { Task } from '../../types/task'

// Temporary sample data for testing
const sampleTasks: Task[] = [
  { id: '1', title: 'Set up project', description: 'Scaffold Vite + React + TypeScript', status: 'completed', createdAt: Date.now() - 86400000 },
  { id: '2', title: 'Add routing', description: 'Configure TanStack Router', status: 'in-progress', createdAt: Date.now() - 3600000 },
  { id: '3', title: 'Build components', description: 'Create TaskCard and TaskList', status: 'pending', createdAt: Date.now() },
]

export const Route = createFileRoute('/_authenticated/tasks')({
  component: Tasks,
})

function Tasks() {
  return (
    <div>
      <h1 className="mb-6 text-2xl font-bold text-gray-900 dark:text-white">Tasks</h1>
      <TaskList
        tasks={sampleTasks}
        onToggle={(id) => console.log('toggle', id)}
        onDelete={(id) => console.log('delete', id)}
      />
    </div>
  )
}

Why is this temporary?

This route uses hardcoded data and console.log for the callbacks. In the next step, you'll replace this with real state management using useReducer and Context. For now, it proves your components render correctly.

  • Save the file. The TanStack Router plugin will automatically detect the new route file and regenerate src/routeTree.gen.ts.

Before you check the browser, what do you expect to see on the Tasks page? Three task cards with different colored status badges, and a row of filter buttons at the top.

  • Open your browser and navigate to your dev server URL.
  • Log in with username admin and password admin.
  • Click the Tasks link in the navigation bar.

You should see three task cards with status badges (yellow for pending, blue for in-progress, green for completed) and four filter buttons at the top. Clicking a filter button narrows the list to only matching tasks.

Don't see the tasks page?

  • Make sure the file is saved at src/routes/_authenticated/tasks.tsx (inside the _authenticated directory, not directly in routes/).
  • Confirm you exported Route using export const Route. The router plugin requires this exact export name to register the route.
  • Check your terminal for errors. If you see a route tree generation error, confirm the file path matches '/_authenticated/tasks' in the createFileRoute call.

Why can't I see my tasks route in the browser?

✔️ Awesome, I've got everything!

Great. Double check that you've saved all three files: src/types/task.ts, src/components/TaskCard.tsx, and src/components/TaskList.tsx.

ⓧ I'd like to double check the full code

Scroll back up and confirm each file contains both chunks in order:

  • src/types/task.ts: the TaskStatus type alias and Task interface.
  • src/components/TaskCard.tsx: chunk 1 (import, interface, statusStyles) followed by chunk 2 (the TaskCard function).
  • src/components/TaskList.tsx: chunk 1 (imports, interface, FilterValue, filterOptions, function signature with state) followed by chunk 2 (the return statement with JSX).

Your task components render beautifully with typed props and local state. Next up, you'll wire these into real state management using useReducer and Context so the toggle and delete buttons actually do something.

Wire Up useReducer, Context, and Theme Toggle

Your authentication flow works and your components are ready to render tasks. The missing piece is shared state. Right now, there's no data flowing between routes and no way for the Dashboard to reflect changes made on the Tasks page.

In this step, you'll build the state management layer with useReducer and Context to share task data across routes. You'll also create a theme toggle using useEffect for DOM side effects and localStorage persistence.

In this step, get ready to:
  • Create a TaskContext with useReducer for managing task state across routes.
  • Create a ThemeContext with useEffect for dark mode toggling persisted to localStorage.
  • Build the AddTaskForm and wire the Dashboard and Tasks routes to live data.
Create the TaskContext with useReducer

When multiple components need to read and update the same data, passing props through every layer becomes unmanageable. useReducer paired with Context solves this by centralizing state transitions in a single function and making that state accessible anywhere in the tree.

  • Create the file src/context/TaskContext.tsx and add the type definitions and context setup:
import { createContext, useContext, useReducer } from 'react'
import type { ReactNode } from 'react'
import type { Task } from '../types/task'

type TaskAction =
  | { type: 'ADD_TASK'; payload: { title: string; description: string } }
  | { type: 'TOGGLE_TASK'; payload: { id: string } }
  | { type: 'DELETE_TASK'; payload: { id: string } }

interface TaskContextType {
  tasks: Task[]
  dispatch: React.Dispatch<TaskAction>
}

const TaskContext = createContext<TaskContextType | null>(null)

What does this code do?

TaskAction is a discriminated union type. Each variant has a type string that TypeScript uses to narrow the payload shape inside the reducer's switch cases.

TaskContextType describes what consumers receive: the current tasks array and a dispatch function to send actions.

  • Below the createContext line, add the default tasks that populate the app on first load:
const defaultTasks: Task[] = [
  {
    id: '1',
    title: 'Set up project',
    description: 'Scaffold Vite + React + TypeScript',
    status: 'completed',
    createdAt: Date.now() - 86400000,
  },
  {
    id: '2',
    title: 'Add routing',
    description: 'Configure TanStack Router with file-based routing',
    status: 'in-progress',
    createdAt: Date.now() - 3600000,
  },
  {
    id: '3',
    title: 'Build components',
    description: 'Create TaskCard and TaskList components',
    status: 'pending',
    createdAt: Date.now(),
  },
]

Why hardcoded default tasks?

Starting with sample data lets you immediately see components rendering real content. Each task has a different status so you can test filtering and toggling right away. The createdAt timestamps use relative offsets from Date.now() so they always look recent.

  • Below defaultTasks, add the reducer function that handles each action immutably:
function taskReducer(state: Task[], action: TaskAction): Task[] {
  switch (action.type) {
    case 'ADD_TASK':
      return [
        ...state,
        {
          id: crypto.randomUUID(),
          title: action.payload.title,
          description: action.payload.description,
          status: 'pending',
          createdAt: Date.now(),
        },
      ]
    case 'TOGGLE_TASK':
      return state.map((task) =>
        task.id === action.payload.id
          ? {
              ...task,
              status: task.status === 'completed' ? 'pending' : 'completed',
            }
          : task,
      )
    case 'DELETE_TASK':
      return state.filter((task) => task.id !== action.payload.id)
    default:
      return state
  }
}

What does the reducer do?

A reducer is a pure function that takes current state and an action, then returns new state without mutating the original. Each case spreads or filters the array to produce a fresh reference.

  • ADD_TASK spreads the existing array and appends a new task with a unique ID from crypto.randomUUID().
  • TOGGLE_TASK maps over tasks and flips the matching task between completed and pending.
  • DELETE_TASK filters out the task with the matching ID.
  • Finally, add the provider component and custom hook at the bottom of the file:
export function TaskProvider({ children }: { children: ReactNode }) {
  const [tasks, dispatch] = useReducer(taskReducer, defaultTasks)

  return <TaskContext.Provider value={{ tasks, dispatch }}>{children}</TaskContext.Provider>
}

export function useTaskContext() {
  const context = useContext(TaskContext)
  if (!context) {
    throw new Error('useTaskContext must be used within a TaskProvider')
  }
  return context
}

What does this code do?

TaskProvider initializes useReducer with taskReducer and defaultTasks, then passes both tasks and dispatch down through Context.

The useTaskContext hook throws a helpful error if called outside a provider. Any component inside the _authenticated layout can call it to read tasks or dispatch actions.

  • Save src/context/TaskContext.tsx.
  • Check your terminal where pnpm dev is running. The import error from _authenticated.tsx should now be resolved and the server should compile cleanly.

Still seeing a compile error?

  • Confirm the file is saved at exactly src/context/TaskContext.tsx. A typo in the path means the import won't resolve.
  • Make sure you exported both TaskProvider and useTaskContext with the export keyword.
  • Check that the import path in _authenticated.tsx reads ../context/TaskContext (no file extension).

Help me fix my TaskContext import error

✔️ 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

Here is the complete src/context/TaskContext.tsx. Confirm each section is present in order: imports, TaskAction type, TaskContextType interface, createContext, defaultTasks, taskReducer, TaskProvider, and useTaskContext.

Create the ThemeContext with useEffect

Dark mode requires syncing React state with something outside the component tree: the dark CSS class on the <html> element. This is a textbook case for useEffect.

  • Create the file src/context/ThemeContext.tsx and add the type setup and initializer function:
import { createContext, useContext, useState, useEffect } from 'react'
import type { ReactNode } from 'react'

type Theme = 'light' | 'dark'

interface ThemeContextType {
  theme: Theme
  toggleTheme: () => void
}

const ThemeContext = createContext<ThemeContextType | null>(null)

function getInitialTheme(): Theme {
  if (typeof window === 'undefined') return 'light'
  const stored = localStorage.getItem('theme')
  if (stored === 'dark' || stored === 'light') return stored
  return 'light'
}

What does getInitialTheme do?

This function reads the saved theme preference from localStorage on first render. If no preference is stored (first visit), it defaults to light. The typeof window === 'undefined' guard prevents errors if the code ever runs in a server-side context.

  • Below getInitialTheme, add the provider and custom hook:
export function ThemeProvider({ children }: { children: ReactNode }) {
  const [theme, setTheme] = useState<Theme>(getInitialTheme)

  useEffect(() => {
    if (theme === 'dark') {
      document.documentElement.classList.add('dark')
    } else {
      document.documentElement.classList.remove('dark')
    }
    localStorage.setItem('theme', theme)
  }, [theme])

  function toggleTheme() {
    setTheme((prev) => (prev === 'light' ? 'dark' : 'light'))
  }

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>{children}</ThemeContext.Provider>
  )
}

export function useTheme() {
  const context = useContext(ThemeContext)
  if (!context) {
    throw new Error('useTheme must be used within a ThemeProvider')
  }
  return context
}

What does the useEffect do here?

Every time theme changes, the effect adds or removes the dark class on document.documentElement (the <html> tag). Tailwind's dark: variants activate when that class is present.

The effect also writes to localStorage so the choice persists across page refreshes. The [theme] dependency array means the effect only re-runs when the theme value actually changes.

  • Save src/context/ThemeContext.tsx.
  • Switch back to your browser. The page should now load without errors. Click the **Dark** button in the nav bar.
  • The background should switch to a dark gray and the button text should change to **Light**.

Theme toggle not working?

  • If you see an import error for ThemeContext, confirm the file path matches exactly: src/context/ThemeContext.tsx.
  • If clicking the button does nothing visible, check that your src/index.css contains the @variant dark override. Without it, Tailwind ignores the dark class.

Help me debug my dark mode toggle

✔️ 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

Here is the complete src/context/ThemeContext.tsx. Confirm you have: imports, Theme type, ThemeContextType interface, createContext, getInitialTheme, ThemeProvider with useEffect, and useTheme hook.

Build AddTaskForm and wire up the routes

The contexts are ready. Now you need components that consume them. The AddTaskForm dispatches actions to add tasks, and the route pages read the shared state to display live data.

  • Create src/components/AddTaskForm.tsx with the form logic:
import { useState } from 'react'
import { useTaskContext } from '../context/TaskContext'

export function AddTaskForm() {
  const { dispatch } = useTaskContext()
  const [title, setTitle] = useState('')
  const [description, setDescription] = useState('')

  function handleSubmit(e: React.FormEvent) {
    e.preventDefault()
    if (!title.trim()) return

    dispatch({
      type: 'ADD_TASK',
      payload: { title: title.trim(), description: description.trim() },
    })

    setTitle('')
    setDescription('')
  }

What does this code do?

The form uses useState for the two input fields (local UI state) and useTaskContext to grab dispatch from the global task state. On submit, it dispatches an ADD_TASK action and resets the fields to empty strings.

  • Still in the same file, add the JSX return below handleSubmit:
  return (
    <form onSubmit={handleSubmit} className="mb-6 rounded-lg border border-gray-200 p-4 dark:border-gray-700 dark:bg-gray-800">
      <h2 className="mb-3 text-lg font-semibold dark:text-white">Add New Task</h2>
      <div className="space-y-3">
        <input
          type="text"
          placeholder="Task title"
          value={title}
          onChange={(e) => setTitle(e.target.value)}
          className="w-full rounded border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400"
        />
        <input
          type="text"
          placeholder="Description (optional)"
          value={description}
          onChange={(e) => setDescription(e.target.value)}
          className="w-full rounded border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400"
        />
        <button
          type="submit"
          className="rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
        >
          Add Task
        </button>
      </div>
    </form>
  )
}
  • Save src/components/AddTaskForm.tsx.

Now create the Dashboard route that displays live task counts.

  • Create src/routes/_authenticated/index.tsx with the Dashboard component:
import { createFileRoute } from '@tanstack/react-router'
import { useTaskContext } from '../../context/TaskContext'

export const Route = createFileRoute('/_authenticated/')({
  component: Dashboard,
})

function Dashboard() {
  const { tasks } = useTaskContext()

  const stats = {
    total: tasks.length,
    pending: tasks.filter((t) => t.status === 'pending').length,
    inProgress: tasks.filter((t) => t.status === 'in-progress').length,
    completed: tasks.filter((t) => t.status === 'completed').length,
  }

  return (
    <div>
      <h1 className="mb-6 text-2xl font-bold text-gray-900 dark:text-white">Dashboard</h1>
      <div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
        <StatCard label="Total" count={stats.total} color="bg-gray-100 dark:bg-gray-800" />
        <StatCard label="Pending" count={stats.pending} color="bg-yellow-100 dark:bg-yellow-900" />
        <StatCard label="In Progress" count={stats.inProgress} color="bg-blue-100 dark:bg-blue-900" />
        <StatCard label="Completed" count={stats.completed} color="bg-green-100 dark:bg-green-900" />
      </div>
    </div>
  )
}

What does this code do?

The Dashboard pulls tasks from context and derives four counts from it. These are computed values, not stored state. Every time a task is added or toggled anywhere in the app, React re-renders this component and the counts update automatically.

  • Below the Dashboard function in the same file, add the StatCard helper component:
function StatCard({ label, count, color }: { label: string; count: number; color: string }) {
  return (
    <div className={`rounded-lg p-4 ${color}`}>
      <p className="text-sm text-gray-600 dark:text-gray-400">{label}</p>
      <p className="text-2xl font-bold text-gray-900 dark:text-white">{count}</p>
    </div>
  )
}
  • Save src/routes/_authenticated/index.tsx.
  • Log in with admin / admin in your browser. You should see the Dashboard with four stat cards: Total (3), Pending (1), In Progress (1), and Completed (1).

Dashboard showing 0 for all counts?

  • Confirm that _authenticated.tsx wraps <Outlet /> in <TaskProvider>. Without the provider, useTaskContext throws.
  • Check that defaultTasks in TaskContext.tsx has three objects in the array.

Help me debug my Dashboard stats

Now wire up the Tasks page to display the task list with add, toggle, and delete functionality.

  • Create src/routes/_authenticated/tasks.tsx with the following code:
import { createFileRoute } from '@tanstack/react-router'
import { useTaskContext } from '../../context/TaskContext'
import { TaskList } from '../../components/TaskList'
import { AddTaskForm } from '../../components/AddTaskForm'

export const Route = createFileRoute('/_authenticated/tasks')({
  component: Tasks,
})

function Tasks() {
  const { tasks, dispatch } = useTaskContext()

  return (
    <div>
      <h1 className="mb-6 text-2xl font-bold text-gray-900 dark:text-white">Tasks</h1>
      <AddTaskForm />
      <TaskList
        tasks={tasks}
        onToggle={(id) => dispatch({ type: 'TOGGLE_TASK', payload: { id } })}
        onDelete={(id) => dispatch({ type: 'DELETE_TASK', payload: { id } })}
      />
    </div>
  )
}

What does this code do?

The Tasks page pulls both tasks and dispatch from context. It passes tasks down to TaskList as a prop and wires the onToggle and onDelete callbacks to dispatch the matching actions.

This is the core React pattern: state flows down via props, events flow up via callbacks, and the shared state lives in Context.

  • Save src/routes/_authenticated/tasks.tsx.

Before you test, what do you expect to happen when you add a task on the Tasks page and then navigate to Dashboard?

  • In your browser, click **Tasks** in the nav bar.
  • Type Write tests in the title field and click **Add Task**.
  • You should see a fourth task card appear in the list with status pending.
  • Click **Dashboard** in the nav bar. The Total count should now read 4 and Pending should read 2.

Both routes read from the same Context, so changes on one page immediately appear on the other. No prop drilling required.

  • Click the theme toggle button in the nav. Confirm that all components (cards, forms, stat boxes, nav) switch between light and dark styles.
  • Refresh the page. The theme preference should persist because it is stored in localStorage.

Tasks not syncing with the Dashboard?

  • Make sure the route file path is src/routes/_authenticated/tasks.tsx (inside the _authenticated folder). If it's outside that folder, it won't be wrapped by TaskProvider.
  • If adding a task does nothing, check that handleSubmit in AddTaskForm.tsx dispatches ADD_TASK (not add_task). The string must match the reducer's case exactly.

Help me debug my task state not updating across routes

Your task manager now has real state management with useReducer, shared state via Context, and a working dark mode toggle with useEffect. Next, you'll take it further with localStorage persistence and interval-based side effects in the Secret Mission.

Secret mission

Add Persistence and Live Timestamps with useEffect

Your tasks vanish on every page refresh. That is the gap this mission closes. You will wire up localStorage persistence using useEffect and useReducer's lazy initializer pattern, then build a live "time ago" display that ticks every second and cleans up its interval on unmount.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs or cloud resources to worry about. Decide whether to keep your project files, pause the dev server, or delete everything entirely.

Resources you used:

  • The task-manager project folder on your Desktop.
  • Node modules installed via pnpm inside the project folder.
  • Browser localStorage entries (task-manager-tasks and theme).

Keep everything running

No action needed. Choose this if you plan to keep building on this project or want to revisit the code later.

  • Your task-manager folder stays on your Desktop.
  • Run pnpm dev from inside the folder any time you want to start the dev server again.
  • Your tasks persist in localStorage, so they will still be there next time you open the app.

Pause - I'll come back to this later

Stop the dev server to free up your terminal. Your files and localStorage data remain intact for when you return.

  • Switch to the terminal running the dev server.
  • Press Ctrl+C to stop the Vite dev server.
  • Your project folder and all files remain on your Desktop, ready to resume with pnpm dev.

Delete - I don't want to use this again

Remove all project files and browser data to start completely fresh.

  • Stop the dev server by pressing Ctrl+C in the terminal if it is still running.
  • Delete the project folder by running this command on macOS/Linux:
cd ~/Desktop && rm -rf task-manager

On Windows, run:

cd ~\Desktop; Remove-Item -Recurse -Force task-manager
  • Clear the localStorage entries by opening your browser's DevTools (F12), navigating to the Application tab, expanding Local Storage, selecting http://localhost:5173, and deleting the task-manager-tasks and theme keys.

Nice Work!

Nice Work!

You just built a full Task Manager Dashboard with authentication, protected routes, and dark mode. This is a real, multi-route React application that uses the core patterns professional teams rely on every day.

Here's what you learned:

  • Built a multi-route React app with TanStack Router file-based routing, pathless layout routes for auth guards, and beforeLoad/redirect to protect authenticated views.
  • Managed complex state with useReducer for task CRUD operations and Context to share auth, tasks, and theme across routes without prop drilling.
  • Created reusable components with typed props and callback patterns, plus a dark/light theme toggle using useEffect to sync with the DOM and localStorage.
  • Secret Mission: Added localStorage persistence for tasks with lazy initialization and a live RelativeTime component using useEffect with proper interval cleanup.

Ready to quiz yourself?