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).
7. Optional badge on the Saved link
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/savedwith statussaved. - Change status to
applied/interviewing→ persists after refresh. - Unsave removes it from Saved.
- Visiting
/savedwhile 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-pinsinlocalStorage
Done when: pin / priority / unpin work and survive refresh.
Next: memoized selectors, HireBoard polish, then submit Taskly in the Final Assessment.