Introduction
Recently I worked on a feature that revolves around expiring quotes in a multi-step fintech flow. What made this interesting was the frontend architecture behind it. State management, timing-sensitive UX, dynamic forms, and render performance all collided here, and each one forced a real design decision.
Here's how I approached the trickiest parts.
The snippets below are simplified examples intended to illustrate the architecture rather than production code.
The Problem: Three Things Happening at Once
A flow like this has three jobs that all have to stay in sync:
- Let the user pick from a set of options, and render the right form for whatever combination they land on.
- Fetch a quote from the backend and track exactly when it expires.
- Make sure an expired quote never gets submitted, at any stage of the flow.
Each of these became its own small design decision.
Splitting State: TanStack Query vs Redux
The first question was where each piece of state should actually live. It's tempting to put everything in one place, but server state and app-derived state behave differently, and mixing them is usually where unnecessary re-renders creep in.
I split it like this:
- TanStack Query owns anything that mirrors the server directly: the quote object, route config, fees. This gets me caching, loading/error states, and deduping for free.
-
Redux owns
quoteExpiresAtandquoteExpiryCountdownTime, values derived once at quote creation that the rest of the app needs to read independently of the fetch lifecycle.
My first instinct was to just read the expiry off the query response wherever I needed it. That broke down for two reasons. First, the countdown needs a stable anchor: the moment a quote is created is the moment the clock starts, and deriving "time remaining" from a field buried in query data on every render means no single source of truth for when that clock actually started. Second, several unrelated components (the quote summary, the submit button, a route guard) all need to check "is this expired?" independently, without subscribing to the whole query object and re-rendering on every unrelated field change in it.
So at quote creation, I dispatch the expiry data into Redux once:
const quoteSlice = createSlice({
name: 'quote',
initialState: {
quoteExpiresAt: null, // epoch ms, the source of truth
quoteExpiryCountdownTime: null, // seconds, for the UI
},
reducers: {
setQuoteExpiry(state, action) {
state.quoteExpiresAt = action.payload.expiresAt;
state.quoteExpiryCountdownTime = action.payload.durationSeconds;
},
clearQuoteExpiry(state) {
state.quoteExpiresAt = null;
state.quoteExpiryCountdownTime = null;
},
},
});
quoteExpiresAt is the absolute truth, an epoch timestamp. Anything in the flow can check expiry by comparing it to Date.now(), which is what let me validate the quote at every stage (route selection, form review, final confirmation) using the same cheap check instead of re-deriving it each time.
The Countdown Timer, Without the Memory Leaks
Countdown timers are a classic source of leaks in React: an interval that outlives its component, or a closure that holds a stale value. I wrapped mine in a hook with a strict cleanup:
function useQuoteCountdown(expiresAt) {
const [secondsLeft, setSecondsLeft] = useState(null);
useEffect(() => {
if (!expiresAt) {
setSecondsLeft(null);
return;
}
const tick = () => {
const remaining = Math.max(0, Math.round((expiresAt - Date.now()) / 1000));
setSecondsLeft(remaining);
};
tick();
const intervalId = setInterval(tick, 1000);
return () => clearInterval(intervalId);
}, [expiresAt]);
return secondsLeft;
}
Two details mattered more than they look. The interval recomputes from the absolute expiresAt timestamp on every tick instead of decrementing a local counter. That self-corrects for drift from tab throttling, since browsers deprioritize setInterval in background tabs. And expiresAt is the only dependency, so the effect resets when a new quote is created, not on every tick.
The cleanup function (clearInterval in the return) is the whole memory-leak fix. Skip it and every remount of that component stacks another interval on top of the last one.
What Actually Happens When a Quote Expires
Tracking expiry is only half the problem. The other half is deciding how the application should recover when a quote expires.
One approach is to validate expiry only at points where the quote is actually required. There's little value in interrupting someone while they're still completing a form, since no transaction is happening yet.
A common pattern is to validate the quote when the user reaches a review or confirmation step. If the quote has expired, the application can request a fresh quote before allowing the user to continue, so they're reviewing up-to-date information instead of hitting an unexpected error later.
Another useful validation point is immediately before submitting the final transaction request. Identity verification or other confirmation steps can introduce delays, so performing one final expiry check helps prevent stale quotes from reaching the backend.
The broader principle is simple: validate immediately before any expiry-sensitive operation, and design recovery around the user's context instead of treating every expiry the same way.
Dynamic Form Generation, the DRY Way
The other half of the feature was rendering the right form for the right route and channel, without a separate form component for every combination.
I treated form structure as data instead of JSX. In this kind of architecture, the backend can expose field metadata describing each input (name, type, label, placeholder, and select options), which lets the frontend generate forms dynamically. Two helper functions turn that metadata into everything the form needs:
function buildFormFields(routeConfig) {
return routeConfig.map((field) => ({
name: field.key,
label: field.label,
placeholder: field.placeholder ?? `Enter ${field.label.toLowerCase()}`,
type: field.inputType,
options: field.inputType === 'select'
? field.options.map(o => ({ label: o.displayName, value: o.code }))
: undefined,
required: field.required,
}));
}
function buildValidationSchema(fields) {
const shape = fields.reduce((acc, field) => {
let validator = field.type === 'select'
? yup.string().oneOf(field.options?.map(o => o.value) ?? [])
: yup.string();
if (field.required) {
validator = validator.required(`${field.label} is required`);
}
return { ...acc, [field.name]: validator };
}, {});
return yup.object().shape(shape);
}
The form component itself never branches on which route is selected. It just builds fields and a schema from whatever config comes back, and maps over them.
Why not hardcode forms per route? That was the obvious starting point, and it's where I almost went. But every new route or channel would mean a frontend deploy, and the list of routes was only going to grow. Generating the form from metadata meant adding a route became a backend change, not a frontend one.
This approach also depends on agreeing on a stable API contract early. Consistent field names, predictable input types, and uniform option structures make it possible to keep the frontend generic instead of introducing route-specific conditionals.
Keeping It Snappy
Since the countdown updates every second, the goal was to avoid re-rendering the entire flow every second. A few small design choices helped:
- The countdown hook's
setSecondsLeftonly re-renders the small component showing the timer text, not the whole form. - Components read
quoteExpiresAtthrough narrow selectors instead of pulling the whole quote slice, so unrelated state changes don't trigger extra renders. -
buildFormFieldsandbuildValidationSchemaare memoized against the route/channel selection, so they don't rebuild on every unrelated re-render. - TanStack Query's cache means re-selecting a previously viewed route doesn't refetch. Only the form rebuild runs.
Key Takeaways
- Server state and app-derived state aren't the same thing. TanStack Query and Redux can split that work cleanly instead of competing for it.
- Anchor timers to an absolute timestamp, never a decrementing counter, or you'll drift under tab throttling.
- Always pair
setIntervalwithclearIntervalin the same effect. It's one line and it prevents a whole category of bugs. - Dynamic forms are a data-transformation problem if the backend contract is right. Get that contract agreed early, and the frontend logic stays a couple of pure functions instead of a pile of conditionals.
Have you had to build something similar, time-sensitive data that has to stay in sync across a multi-step flow? Curious how others have split this between server and client state. Let's discuss in the comments!
Top comments (0)