React Context and Custom Hooks: Sharing Auth State and Reusing Data Logic

MERN Stack Full Course: MongoDB, Express, React and Node.js · React Fundamentals · 8 min read · Updated

Share state without props

  1. Some data is needed in many places, such as the signed-in user. Passing props through every level is tedious.
  2. A context provider keeps the data and makes it available to everything inside it.
  3. A custom hook, useAuth, hides the context behind one simple call.
  4. Any component calls useAuth and gets the user, with no props needed.
  5. Hooks that fetch data, like useJobs, put the loading, error and reload logic in one reusable place.

Two problems appear as the app grows. First, many components need the same information, such as who is signed in, and passing it as props through every level (called *prop drilling*) is tedious and fragile. Second, the same loading logic gets copied from page to page. React answers the first with context and the second with custom hooks.

Context: share without passing props

import { createContext, useContext, useEffect, useState } from 'react';
import { api, setToken } from '../api.js';

// Context shares a value with every component below it, without passing props through each level.
const AuthContext = createContext(null);

export function AuthProvider({ children }) {
  const [user, setUser] = useState(null);
  const [ready, setReady] = useState(false); // false until we have tried to sign in

  useEffect(() => {
    const controller = new AbortController();
    (async () => {
      try {
        // TEMPORARY: sign in as the demo user from `npm run seed`. Lesson 32 replaces this with real sign-in pages.
        const res = await api('/auth/login', { method: 'POST', body: { email: 'demo@example.com', password: 'Demo12345' }, signal: controller.signal });
        setToken(res.token);
        setUser(res.user);
      } catch { /* the pages show their own errors */ }
      if (!controller.signal.aborted) setReady(true);
    })();
    return () => controller.abort();
  }, []);

  return <AuthContext.Provider value={{ user, ready }}>{children}</AuthContext.Provider>;
}

// A custom hook: a function whose name starts with "use" and that calls other hooks.
export const useAuth = () => {
  const ctx = useContext(AuthContext);
  if (!ctx) throw new Error('useAuth must be used inside <AuthProvider>');
  return ctx;
};
  • createContext(null) makes a context. A provider component puts a value into it, and any component below the provider can read that value, however deep.
  • AuthProvider owns the state (user and ready) and offers them through value. children is whatever you wrap it around.
  • useAuth() is a custom hook: a function whose name starts with use that calls other hooks (useContext here). It reads the context and throws a clear error if you forget to wrap the app in the provider.
  • ready is false until the first attempt to sign in has finished. Pages can wait for it, so they do not flash a “signed out” screen to someone who is, in fact, signed in.
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App.jsx';
import { AuthProvider } from './context/AuthContext.jsx';
import './index.css';

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <BrowserRouter>
      <AuthProvider>
        <App />
      </AuthProvider>
    </BrowserRouter>
  </StrictMode>,
);

Here AuthProvider wraps App, inside BrowserRouter so that later code can navigate. As before, the effect signs in as the demo user. It is temporary and lives in one place now, rather than in every page, which makes it easy to remove in lesson 32.

Custom hooks: reuse logic

import { useCallback, useEffect, useState } from 'react';
import { api } from '../api.js';
import { useAuth } from '../context/AuthContext.jsx';

// All the "load a list from the API" logic in one place, so a page only says what it wants to show.
export function useJobs() {
  const { ready, user } = useAuth();
  const [jobs, setJobs] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState('');

  const load = useCallback(async (signal) => {
    setLoading(true);
    setError('');
    try {
      const page = await api('/jobs?limit=50', { signal });
      setJobs(page.items);
    } catch (err) {
      if (err.name !== 'AbortError') setError(err.message);
    } finally {
      if (!signal?.aborted) setLoading(false);
    }
  }, []);

  useEffect(() => {
    if (!ready) return undefined;
    if (!user) { setLoading(false); setError('Could not sign in. Is the server running, and did you run npm run seed?'); return undefined; }
    const controller = new AbortController();
    load(controller.signal);
    return () => controller.abort();
  }, [ready, user, load]);

  return { jobs, setJobs, loading, error, reload: () => load() };
}

useJobs moves the list loading out of the page. It uses useAuth to wait for the sign-in, useState for the data, loading and error, and useEffect to fetch with an abort controller. It returns what a page needs: jobs, loading, error. A custom hook is just a function, and every component that calls it gets its own copy of the state. They share the *code*, not the *data*.

useCallback wraps load so that the function keeps the same identity between draws. That matters here because load appears in an effect's dependency array: a new function on every draw would make the effect run again and again.

import { Link } from 'react-router-dom';
import JobCard from '../components/JobCard.jsx';
import JobForm from '../components/JobForm.jsx';
import { useJobs } from '../hooks/useJobs.js';

export default function JobsPage() {
  const { jobs, setJobs, loading, error } = useJobs();

  const changeStatus = (id, status) => setJobs((cur) => cur.map((j) => (j.id === id ? { ...j, status } : j)));
  const addJob = (values) => setJobs((cur) => [{ ...values, id: `local-${Date.now()}` }, ...cur]);

  return (
    <>
      <h1>My jobs ({jobs.length})</h1>
      <JobForm onSubmit={addJob} />
      {loading && <p className="muted">Loading your jobs…</p>}
      {error && <p className="error" role="alert">{error}</p>}
      {jobs.map((job) => (
        <div key={job.id}>
          <JobCard job={job} onStatusChange={changeStatus} />
          <p className="muted"><Link to={`/jobs/${job.id}`}>Open details</Link></p>
        </div>
      ))}
    </>
  );
}

Compare this page with lesson 29. It went from about 35 lines of effects and states to a single call, useJobs(). The page now says what to show; the hook knows how to get it. This is the main reason to write a custom hook: make the pages read like a description of the screen.

import { useEffect, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { api } from '../api.js';
import { useAuth } from '../context/AuthContext.jsx';
import StatusBadge from '../components/StatusBadge.jsx';

export default function JobDetailPage() {
  const { id } = useParams();
  const { ready } = useAuth();
  const [job, setJob] = useState(null);
  const [error, setError] = useState('');

  useEffect(() => {
    if (!ready) return undefined; // wait until the temporary sign-in has finished
    const controller = new AbortController();
    api(`/jobs/${id}`, { signal: controller.signal })
      .then(setJob)
      .catch((err) => err.name !== 'AbortError' && setError(err.status === 404 ? 'That job was not found.' : err.message));
    return () => controller.abort();
  }, [id, ready]);

  if (error) return <p className="error" role="alert">{error} <Link to="/">Back to my jobs</Link></p>;
  if (!job) return <p className="muted">Loading…</p>;
  return (
    <article className="card">
      <h1>{job.role}</h1>
      <p>{job.company}{job.location ? ` · ${job.location}` : ''}</p>
      <StatusBadge status={job.status} />
      {job.notes && <p>{job.notes}</p>}
      <p><Link to="/">← Back to my jobs</Link></p>
    </article>
  );
}

Your task

  1. Run the app and check that it behaves like lesson 29, with the loading logic now in the hook.
  2. Add a useDocumentTitle(title) hook that sets document.title in an effect, and use it in both pages.
  3. In React Developer Tools, find AuthProvider and its state.
  4. Temporarily remove AuthProvider from main.jsx and read the error that useAuth throws.

How to check it worked

  • Behaviour is the same as before, with less code in the pages.
  • useAuth explains the mistake when the provider is missing.

Summary

  • Context shares a value with all components below a provider; use it for rarely changing data like the user.
  • A custom hook is a use... function that reuses stateful logic. Each caller gets its own state.
  • Wrap functions in useCallback when they are dependencies of an effect.

Download the solution (mern-lesson-30.zip)

Frequently asked questions

When should I use context instead of props?

When many components at different depths need the same value, such as the current user, theme or language. For data used by one parent and its direct children, props are simpler and clearer.

Do two components using the same hook share state?

No. Each call to a hook creates its own state. A hook shares logic, not data. To share data, lift the state up or put it in context.

Why does a hook have to start with use?

It is a convention that tools rely on: the linter checks that hooks follow the rules (only call them at the top level of components and other hooks), and readers recognise them instantly.