Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/iDevRanjan/lws-ra-b4-assignment-five/llms.txt

Use this file to discover all available pages before exploring further.

LWS Job Portal deliberately avoids heavyweight state-management libraries. Instead it splits state into two clear layers: a tiny hand-rolled pub-sub store that owns authentication data, and TanStack React Query that owns every piece of server-fetched data. This separation means components only re-render when the slice of state they actually depend on changes, and the query cache handles all the complexity of loading, error, and stale-while-revalidate logic automatically.

Two-Layer State Architecture

Global Auth State

A vanilla JavaScript pub-sub object (src/store/index.js) holds the single authData key. React reads it through useSyncExternalStore — no Context Provider, no Redux, no Zustand.

Server State

TanStack React Query owns all remote data. Every API resource has a typed queryOptions() factory in queryOptions.js so query keys and fetch functions are defined once and reused everywhere.

The Custom Pub-Sub Store

How it works

createStore in src/store/index.js is a factory that returns three functions — getState, setState, and subscribe — backed by a plain object and a Set of listener callbacks. Every call to setState that produces a changed value fans out to all registered listeners synchronously.
// src/store/index.js
function createStore(initialStoreState) {
    let state = { ...initialStoreState };
    const listeners = new Set();

    const keys = {};
    for (const key in initialStoreState) {
        keys[key] = key;
    }

    return {
        getState: () => state,
        subscribe: (listener) => {
            listeners.add(listener);
            return () => listeners.delete(listener);
        },
        setState: (selectKeyFn, nextStateOrFn) => {
            if (typeof selectKeyFn !== "function") {
                console.error(
                    `[Store Error]: The first argument must be a selector function. Usage: setState((keys) => keys.username, nextValue)`,
                );
                return;
            }

            const key = selectKeyFn(keys);

            if (!key || !keys[key]) {
                console.error(
                    `[Store Error]: Selected key "${key}" is invalid or does not exist in the initial state.`,
                );
                return;
            }

            const currentState = state[key];
            const nextState =
                typeof nextStateOrFn === "function"
                    ? nextStateOrFn(currentState)
                    : nextStateOrFn;

            if (!Object.is(currentState, nextState)) {
                state = { ...state, [key]: nextState };
                listeners.forEach((listener) => listener());
            }
        },
    };
}

const initialStoreState = {
    authData:
        getLocalStorageItem(LOCALSTORAGE_KEYS.authData) ?? initialAuthState,
};

export const store = createStore(initialStoreState);
setState uses Object.is to compare the previous and next values. If nothing changed, listeners are not called — preventing unnecessary re-renders.

Key design decisions

Rather than accepting a raw string key, setState requires a selector function (keys) => keys.authData. This gives you autocomplete in IDEs and prevents typos from silently writing to a nonexistent key.
nextStateOrFn can be a function that receives the current value: setState((k) => k.authData, (prev) => ({ ...prev, token: newToken })). This mirrors the useState updater pattern React developers already know.
subscribe returns a cleanup function () => listeners.delete(listener). React’s useSyncExternalStore calls this automatically on unmount, so there are no memory leaks.

Initial auth state

The store is seeded from localStorage on page load. If no persisted value exists, initialAuthState is used:
// src/data/initialAuthState.js
export const initialAuthState = {
    isLoggedin: false,
};
A logged-in session adds role, token, and profile fields when the login action runs.

Auth Actions

src/store/actions/authActions.js exposes two mutations — login and logout — that keep the store, localStorage, and the React Query cache consistent at all times.
// src/store/actions/authActions.js
export const authActions = {
    login: (loginResponseData) => {
        queryClient.removeQueries({
            queryKey: [QUERY_KEYS.clientProfile, null],
            exact: true,
        });
        setLocalStorageItem(LOCALSTORAGE_KEYS.authData, loginResponseData);
        store.setState((keys) => keys.authData, loginResponseData);
    },
    logout: () => {
        queryClient.clear();
        setLocalStorageItem(LOCALSTORAGE_KEYS.authData, initialAuthState);
        store.setState((keys) => keys.authData, initialAuthState);
    },
};
1

login(loginResponseData)

Removes any stale anonymous profile cache entry, persists the JWT payload to localStorage, then updates the store — triggering a synchronous re-render in every useAuth subscriber.
2

logout()

Calls queryClient.clear() to wipe all cached server data (prevents a previously-logged-in user’s data from appearing on the next visit), resets localStorage, and resets the store to initialAuthState.

The useAuth Hook

Components never read the store directly. They consume useAuth, which wires useSyncExternalStore to the pub-sub store and co-locates the auth actions so callers get everything in one import.
// src/hooks/useAuth.js
import { useCallback, useSyncExternalStore } from "react";
import { authActions } from "../store/actions/authActions";
import { store } from "../store";

export function useAuth() {
    const getAuthData = useCallback(() => store.getState().authData, []);
    const authData = useSyncExternalStore(store.subscribe, getAuthData);

    return { authData, ...authActions };
}
useSyncExternalStore is a React 18+ built-in that subscribes to an external store safely — handling concurrent-mode tearing and server-side snapshots without any additional boilerplate. Typical usage in a component:
const { authData, login, logout } = useAuth();

if (authData.isLoggedin) {
    return <span>Welcome, {authData.role}</span>;
}

Filter State: QueryObjectProvider and useQueryObject

The job listing page maintains a rich filter object (search term, job type, experience level, salary range, skills, sort order). This state lives in QueryObjectProvider, which wraps only the <Home /> page — keeping filter state scoped and garbage-collected when the user navigates away.
// src/providers/QueryObjectProvider.jsx (excerpt)
export default function QueryObjectProvider({ children }) {
    const [queryObject, setQueryObject] = useImmer(initialQueryObject);
    const [clearFiltersKey, setClearFiltersKey] = useState(0);

    const handleSetQueryFilter = useCallback(
        (data) => {
            const { name, value } = data;
            setQueryObject((draft) => {
                const fieldMap = {
                    "job-type": "type",
                    "experience-lavel": "experienceLevel",
                    skills: "skills",
                };
                const field = fieldMap[name];
                if (field) {
                    const index = draft[field].indexOf(value);
                    if (index !== -1) {
                        draft[field].splice(index, 1);
                    } else {
                        draft[field].push(value);
                    }
                } else if (name === "salary-range") {
                    const [min, max] = value.split("-").map(Number);
                    draft.minSalary = min ?? null;
                    draft.maxSalary = max ?? null;
                }
            });
        },
        [setQueryObject],
    );
    // ...
}
The initial filter shape mirrors URL query parameters:
// src/data/initialQueryObject.js
export const initialQueryObject = {
    search: "",
    type: [],
    experienceLevel: [],
    minSalary: null,
    maxSalary: null,
    skills: [],
    sort: "",
};
Child components access filter state and setters through useQueryObject:
// src/hooks/useQueryObject.js
export function useQueryObject() {
    const context = useContext(QueryObjectContext);

    if (!context) {
        throw new Error(
            "useQueryObject must be used within a QueryObjectProvider",
        );
    }

    return context;
}
useQueryObject throws an error if called outside QueryObjectProvider. This is intentional — it surfaces misconfigured component trees at development time rather than silently returning undefined.

The useDebounce Hook

Search inputs call handleSetQuerySearch on every keystroke. Without debouncing, this would fire a new API request for every character typed. useDebounce wraps any function and delays its execution until the user stops typing:
// src/hooks/useDebounce.js
import { useRef } from "react";

export function useDebounce(func, delay = 300) {
    const timeoutId = useRef(null);

    return function (...args) {
        if (timeoutId.current) {
            clearTimeout(timeoutId.current);
        }

        timeoutId.current = setTimeout(() => {
            func.apply(this, args);
            timeoutId.current = null;
        }, delay);
    };
}
useRef persists the timeout ID across renders without causing re-renders of its own. The default delay is 300 ms, which is imperceptible to users but reduces API calls by an order of magnitude during fast typing.

TanStack React Query Setup

QueryClient configuration

src/services/queryClient.js creates the singleton QueryClient shared by the entire app:
// src/services/queryClient.js
import { QueryClient } from "@tanstack/react-query";

export const queryClient = new QueryClient({
    defaultOptions: {
        queries: {
            staleTime: 1 * 60 * 1000,       // 1 minute
            gcTime: 2.5 * 60 * 1000,         // 2.5 minutes
            refetchOnMount: false,
            refetchOnReconnect: false,
            refetchOnWindowFocus: false,
        },
    },
});
Disabling refetchOnMount, refetchOnReconnect, and refetchOnWindowFocus prevents waterfall re-fetches when users tab back to the app. The 1-minute staleTime means data is considered fresh for 60 seconds — long enough for casual browsing without stale job listings.

queryOptions.js — reusable query factories

Every query is defined as a factory function returning a queryOptions() object. This pattern means the query key and fetch function are co-located and can be used identically in both hooks (useQuery) and router loaders (queryClient.ensureQueryData):
// src/services/queryOptions.js (selected entries)
export function getAllJobsQueryOption(params) {
    return queryOptions({
        queryKey: [QUERY_KEYS.allJobs, params],
        queryFn: ({ pageParam }) => getAllJobs(pageParam, params),
        initialPageParam: 1,
        placeholderData: keepPreviousData,
        getNextPageParam: (lastPage, allPages) => {
            if (lastPage.data.length === 0) {
                return undefined;
            }
            return allPages.length + 1;
        },
    });
}

export function getClientProfileQueryOption(authData) {
    return queryOptions({
        queryKey: [QUERY_KEYS.clientProfile, authData.role],
        queryFn: () => {
            if (authData.role === "USER") return getUserProfile();
            if (authData.role === "COMPANY") return getCompanyProfile();
            throw new Error("Invalid role");
        },
        enabled: authData.isLoggedin,
        retry: false,
    });
}

Query key conventions

All query keys follow the pattern [QUERY_KEYS.<name>, params] where params is the variable part (slug, filter object, page number, etc.). String constants live in src/utils/constants.js:
export const QUERY_KEYS = {
    allJobs: "all-jobs",
    jobBySlug: "job-by-slug",
    similarJobs: "similar-jobs",
    companyBySlug: "company-by-slug",
    companyOpenPositions: "company-open-positions",
    clientProfile: "client-profile",
    jobSeekerApplications: "job-seeker-applications",
    recommendedJobs: "recommended-jobs",
    companyDashboardStats: "company-dashboard-stats",
    companyOpenPositionsForOwn: "company-open-positions-for-own",
    companyApplicants: "company-applicants",
    applicantProfile: "applicant-profile",
};

mutationOptions.js — reusable mutation configs

Mutations follow the same factory pattern. Side-effects like cache invalidation live inside onSuccess callbacks:
// src/services/mutationOptions.js (excerpt)
export function applyAJobMutationOption(jobId) {
    return mutationOptions({
        mutationFn: (coverLetterFormData) =>
            applyAJob(coverLetterFormData, jobId),
        onSuccess: async () => {
            await queryClient.invalidateQueries({
                queryKey: [QUERY_KEYS.jobSeekerApplications],
            });
        },
    });
}

export function withdrawApplicationMutationOption(applicationId) {
    return mutationOptions({
        mutationFn: () => withdrawApplication(applicationId),
        onSuccess: async () => {
            await queryClient.invalidateQueries({
                queryKey: [QUERY_KEYS.jobSeekerApplications],
            });
        },
    });
}

Compound Hooks

Two hooks compose useAuth and useQuery into single-import utilities for common data needs:
// src/hooks/useProfile.js
export function useProfile() {
    const { authData } = useAuth();
    return useQuery(getClientProfileQueryOption(authData));
}
Internally uses getClientProfileQueryOption which sets enabled: authData.isLoggedin, so the query only fires when the user is authenticated.
This architecture intentionally avoids Redux, Zustand, and heavy Context usage. The pub-sub store is a 40-line vanilla JS object. The only React-specific primitive it touches is useSyncExternalStore — making the store itself framework-agnostic and trivially testable.

Build docs developers (and LLMs) love