Goal

Track saved jobs and application status (saved, applied, interviewing, offer, rejected) in an applications slice, wire Save / status controls on JobCard, and build the Saved page.

1. Client state vs server state

Kind Example in HireBoard Tool
Server / remote Job listings RTK Query
Client / user choices Which jobs you saved + status createSlice

Mixing both is normal. Do not force saves into RTK Query unless a real API owns them.

2. Create src/store/applicationsSlice.js

import { createSlice } from "@reduxjs/toolkit";

const saved = localStorage.getItem("hireboard-applications");

const applicationsSlice = createSlice({
  name: "applications",
  initialState: {
    items: saved ? JSON.parse(saved) : {},
  },
  reducers: {
    saveJob(state, action) {
      const job = action.payload;
      if (!state.items[job.id]) {
        state.items[job.id] = {
          job,
          status: "saved",
          updatedAt: new Date().toISOString(),
        };
      }
      localStorage.setItem(
        "hireboard-applications",
        JSON.stringify(state.items),
      );
    },
    unsaveJob(state, action) {
      const id = action.payload;
      delete state.items[id];
      localStorage.setItem(
        "hireboard-applications",
        JSON.stringify(state.items),
      );
    },
    setStatus(state, action) {
      const { id, status } = action.payload;
      if (state.items[id]) {
        state.items[id].status = status;
        state.items[id].updatedAt = new Date().toISOString();
        localStorage.setItem(
          "hireboard-applications",
          JSON.stringify(state.items),
        );
      }
    },
  },
});

export const { saveJob, unsaveJob, setStatus } = applicationsSlice.actions;
export default applicationsSlice.reducer;

Chunks explained

Chunk What it does
items as an object keyed by job id Fast lookup: items[id]
saveJob Stores full job snapshot + default status
unsaveJob delete state.items[id] (Immer-safe)
setStatus Updates pipeline stage for a saved job
Persist after each change Survives refresh like auth/theme

Status values you will use:

saved → applied → interviewing → offer
                              ↘ rejected

3. Register in the store

import applicationsReducer from "./applicationsSlice";

export const store = configureStore({
  reducer: {
    ui: uiReducer,
    auth: authReducer,
    applications: applicationsReducer,
    [loginApi.reducerPath]: loginApi.reducer,
    [jobsApi.reducerPath]: jobsApi.reducer,
  },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().concat(loginApi.middleware, jobsApi.middleware),
});

4. Upgrade JobCard.jsx

import { useDispatch, useSelector } from "react-redux";
import { saveJob, unsaveJob, setStatus } from "../store/applicationsSlice";

const STATUSES = ["saved", "applied", "interviewing", "offer", "rejected"];

function JobCard({ job, showStatus = false }) {
  const dispatch = useDispatch();
  const entry = useSelector((state) => state.applications.items[job.id]);
  const isSaved = Boolean(entry);

  return (
    <article className="flex flex-col rounded-xl border border-slate-200 bg-card p-5 shadow-sm transition hover:-translate-y-0.5 hover:shadow-md dark:border-slate-700 dark:bg-slate-900">
      <div className="mb-3 flex items-start justify-between gap-3">
        <div>
          <h3 className="text-lg font-semibold text-ink dark:text-white">
            {job.title}
          </h3>
          <p className="text-sm text-brand-600">{job.company}</p>
        </div>
        <span className="rounded-full bg-brand-50 px-2.5 py-1 text-xs font-medium text-brand-700 dark:bg-brand-900/40 dark:text-brand-100">
          {job.type}
        </span>
      </div>

      <p className="mb-4 flex-1 text-sm text-muted dark:text-slate-400">
        {job.description}
      </p>

      <div className="mb-4 flex flex-wrap gap-2">
        {job.tags.map((tag) => (
          <span
            key={tag}
            className="rounded-md bg-slate-100 px-2 py-1 text-xs text-slate-600 dark:bg-slate-800 dark:text-slate-300"
          >
            {tag}
          </span>
        ))}
      </div>

      <div className="mb-4 flex items-center justify-between border-t border-slate-100 pt-3 text-sm dark:border-slate-800">
        <span className="text-muted dark:text-slate-400">{job.location}</span>
        <span className="font-medium text-ink dark:text-white">
          {job.salary}
        </span>
      </div>

      <div className="flex flex-wrap items-center gap-2">
        {isSaved ? (
          <button
            type="button"
            onClick={() => dispatch(unsaveJob(job.id))}
            className="rounded-md border border-slate-300 px-3 py-2 text-sm font-medium hover:bg-slate-100 dark:border-slate-600 dark:hover:bg-slate-800"
          >
            Unsave
          </button>
        ) : (
          <button
            type="button"
            onClick={() => dispatch(saveJob(job))}
            className="rounded-md bg-brand-600 px-3 py-2 text-sm font-semibold text-white hover:bg-brand-700"
          >
            Save job
          </button>
        )}

        {showStatus && isSaved && (
          <select
            value={entry.status}
            onChange={(e) =>
              dispatch(setStatus({ id: job.id, status: e.target.value }))
            }
            className="rounded-md border border-slate-300 bg-white px-2 py-2 text-sm dark:border-slate-600 dark:bg-slate-950"
          >
            {STATUSES.map((status) => (
              <option key={status} value={status}>
                {status}
              </option>
            ))}
          </select>
        )}
      </div>
    </article>
  );
}

export default JobCard;

Chunks explained

Chunk What it does
state.applications.items[job.id] Per-card saved entry (or undefined)
dispatch(saveJob(job)) Snapshot the job into Redux
showStatus Status dropdown only on the Saved page
Controlled <select> value={entry.status} + setStatus

5. Create SavedJobs.jsx

import { useSelector } from "react-redux";
import JobCard from "./JobCard";

function SavedJobs() {
  const items = useSelector((state) => state.applications.items);
  const entries = Object.values(items);

  if (entries.length === 0) {
    return (
      <section>
        <h2 className="mb-2 text-2xl font-semibold">Saved applications</h2>
        <p className="text-muted dark:text-slate-400">
          No saved jobs yet. Open <span className="font-medium">Jobs</span> and
          click Save job.
        </p>
      </section>
    );
  }

  return (
    <section>
      <div className="mb-6">
        <h2 className="text-2xl font-semibold">Saved applications</h2>
        <p className="text-sm text-muted dark:text-slate-400">
          {entries.length} saved · update status as you progress
        </p>
      </div>

      <div className="grid gap-4 sm:grid-cols-2">
        {entries.map(({ job }) => (
          <JobCard key={job.id} job={job} showStatus />
        ))}
      </div>
    </section>
  );
}

export default SavedJobs;

Chunks explained

Chunk What it does
Object.values(items) Turn id-map into an array for .map
showStatus Enables the pipeline dropdown
Empty state copy Guides the student what to do next

6. Wire Saved into the /saved route

Update src/routes/_authenticated/saved.jsx:

import { createFileRoute } from "@tanstack/react-router";
import SavedJobs from "../../components/SavedJobs";

export const Route = createFileRoute("/_authenticated/saved")({
  component: SavedPage,
});

function SavedPage() {
  return <SavedJobs />;
}

Also make sure applications is registered in src/store/index.js (see section 3 above).

In Navbar.jsx:

const savedCount = useSelector(
  (state) => Object.keys(state.applications.items).length,
);

// Link children:
Saved{savedCount > 0 ? ` (${savedCount})` : ""}

Check

  • Save a job on /jobs → it appears on /saved with status saved.
  • Change status to applied / interviewing → persists after refresh.
  • Unsave removes it from Saved.
  • Visiting /saved while logged out still redirects to /login.

Assessment: Taskly : Pins slice

Add a client pinsSlice (not RTK Query):

  • Pin a todo from /tasks
  • Show pinned todos on /pinned
  • Set priority: low | medium | high
  • Unpin a todo
  • Persist under taskly-pins in localStorage

Done when: pin / priority / unpin work and survive refresh.

Next: memoized selectors, HireBoard polish, then submit Taskly in the Final Assessment.