Skip to main content

State Management

Maybern separates server state (API data) from client state using appropriate tools for each.

State Categories

CategoryToolExamples
Server StateTanStack QueryAPI data, entities, lists
Global Client StateReact ContextAuth, customer, theme
Local Client StateuseState/useReducerForm state, UI state
URL StateReact RouterFilters, pagination, selections

TanStack Query (Server State)

Fetching Data

import { useFundFamilyRetrieve } from "@/gen/api";

function FundFamilyDetail({ id }: { id: string }) {
  const { data, isLoading, error } = useFundFamilyRetrieve(id);
  
  if (isLoading) return <Spinner />;
  if (error) return <ErrorDisplay error={error} />;
  
  return <FundFamilyCard fundFamily={data} />;
}

Mutations

import { useFundFamilyCreate } from "@/gen/api";

function CreateFundFamily() {
  const mutation = useFundFamilyCreate();
  
  const handleSubmit = async (data: CreateFundFamilyData) => {
    await mutation.mutateAsync(data, {
      onSuccess: (result) => {
        toast.success("Created!");
        navigate(routes.fundFamily.detail({ id: result.id }));
      },
      onError: (error) => {
        toast.error(error.message);
      },
    });
  };
  
  return (
    <FundFamilyForm
      onSubmit={handleSubmit}
      isLoading={mutation.isPending}
    />
  );
}

Query Keys

Generated queries use consistent keys:
// Automatic cache invalidation
queryClient.invalidateQueries({ queryKey: ["fundFamily", id] });

// Prefetching
queryClient.prefetchQuery({
  queryKey: ["fundFamily", id],
  queryFn: () => fetchFundFamily(id),
});

React Context (Global State)

Auth Context

const AuthContext = createContext<AuthState | null>(null);

export function useAuth() {
  const context = useContext(AuthContext);
  if (!context) throw new Error("useAuth must be within AuthProvider");
  return context;
}

// Usage
const { user, customer, logout } = useAuth();

Customer Context

const CustomerContext = createContext<CustomerState | null>(null);

export function useCustomer() {
  const context = useContext(CustomerContext);
  return context; // Can be null if not selected
}

// Usage
const customer = useCustomer();
const customerName = customer?.name ?? "Select Customer";

Local State

Component State

function FilterPanel() {
  const [isExpanded, setIsExpanded] = useState(false);
  const [filters, setFilters] = useState<Filters>({});
  
  return (
    <Box>
      <Button onClick={() => setIsExpanded(!isExpanded)}>
        {isExpanded ? "Collapse" : "Expand"}
      </Button>
      {isExpanded && <FilterForm value={filters} onChange={setFilters} />}
    </Box>
  );
}

Complex Local State

function DataEditor() {
  const [state, dispatch] = useReducer(editorReducer, initialState);
  
  const handleAdd = () => dispatch({ type: "ADD_ROW" });
  const handleUpdate = (id, data) => dispatch({ type: "UPDATE", id, data });
  
  return <Editor state={state} onAdd={handleAdd} onUpdate={handleUpdate} />;
}

URL State

function TransactionList() {
  const [searchParams, setSearchParams] = useSearchParams();
  
  const filters = {
    status: searchParams.get("status") ?? "all",
    page: parseInt(searchParams.get("page") ?? "1"),
    sortBy: searchParams.get("sortBy") ?? "date",
  };
  
  const updateFilters = (newFilters: Partial<typeof filters>) => {
    setSearchParams({
      ...filters,
      ...newFilters,
    });
  };
  
  // Data fetching with URL-derived filters
  const { data } = useTransactionList(filters);
  
  return (
    <TransactionTable
      data={data}
      filters={filters}
      onFilterChange={updateFilters}
    />
  );
}

Best Practices

Use TanStack Query for anything from the API. Let it handle caching, deduplication, and background updates.
Use URL params for state that should be shareable or bookmarkable (filters, selections, pagination).
Only use Context for truly global state (auth, theme). Don’t overuse it.
Use useState for local UI state that doesn’t need to be shared.