Imagine you're working in a logistics dashboard. You filter shipments to see everything that's delayed. You open one record, check its ETA, and start investigating the problem.
Meanwhile, new shipment information arrives from the server. One shipment changes status. Another gets a new ETA. A background request finishes just after you've changed your filters.
What should the interface do?
If every update replaces the entire screen state, users can lose their place. If the application keeps showing old records, the interface becomes unreliable.
We worked with this type of operational data while designing and developing LogiCore, a logistics and warehouse SaaS application at UITOP.
The product connects shipment monitoring, warehouse locations, inventory records, and operational metrics. Its frontend uses React, TypeScript, and Zustand, with data served through REST APIs backed by Node.js, Express, PostgreSQL, and Prisma.
For shipment monitoring, our team used Zustand-managed polling to retrieve updated status, ETA, and timestamp information. That project gives us a useful starting point for a broader engineering question:
How should a React application manage user interactions and changing server data at the same time?
Start by separating three kinds of state
A common source of frontend bugs is treating every piece of information on a screen as one large object.
Consider a shipment monitoring page. It may contain:
- Current shipment records and their statuses
- Active filters and pagination
- The shipment the user has selected
- Loading and refresh indicators
- Errors from API requests
- A timestamp showing when the data was updated
These values have different responsibilities. I find it useful to separate them into three groups.
Server data
This represents information the application receives from its backend.
- Shipment ID
- Current shipment status
- ETA
- Last carrier update
- Total number of records matching a query
The backend determines the current business state. The frontend presents a snapshot of that information.
User interface state
This represents the user's current interaction with the page.
- Active status filter
- Current page
- Selected shipment ID
- Expanded details
- Open dialogs
A background data refresh generally should not reset these choices.
Request state
This describes what's happening while the application communicates with the server.
- Initial loading
- Background refresh in progress
- Last successful response
- Request failure
- Data that may be stale
This third group matters because a screen can have valid data and an active request at the same time. A user should still be able to inspect the current results while the application retrieves newer information.
Make the user's current view explicit
Suppose a shipment table supports two filters: All and Delayed. The user can also navigate between pages and select a shipment. Those interactions can be represented independently from the returned shipment records.
Zustand example:
import { create } from "zustand";
type StatusFilter = "all" | "delayed";
type ShipmentViewState = {
status: StatusFilter;
page: number;
selectedShipmentId: string | null;
setStatus: (status: StatusFilter) => void;
setPage: (page: number) => void;
selectShipment: (id: string | null) => void;
};
export const useShipmentView =
create<ShipmentViewState>((set) => ({
status: "all",
page: 1,
selectedShipmentId: null,
setStatus: (status) =>
set({
status,
page: 1,
selectedShipmentId: null,
}),
setPage: (page) => set({ page }),
selectShipment: (id) =>
set({ selectedShipmentId: id }),
}));
Changing the status filter resets pagination to page 1. The previous page might not exist in the filtered result.
Selection is stored by shipment ID. If shipment records are refreshed, the application can still identify the selected item. The selected object doesn't need to be stored as a separate copy of the entire record.
Whether selection should be cleared when filters change depends on the workflow. In this example, we clear it explicitly. In other products, keeping the detail view open may be preferable.
The important part is making that behavior intentional.
Keep polling separate from table interactions
In LogiCore, shipment status and ETA information are refreshed through background requests to the REST API.
Polling is a reasonable approach when the application needs periodic updates and the source data doesn't require a continuous streaming connection. The challenge is managing those requests as the user changes the current view.
A naive implementation might look like this:
setInterval(async () => {
const response = await fetch("/api/shipments");
const data = await response.json();
setShipments(data);
}, 15000);
It doesn't account for current filters or pagination. It has no error handling. Requests can overlap if one response takes longer than the interval.
It also doesn't address what happens when the component unmounts or the user switches to a different query. A more reliable implementation gives the polling process a clear lifecycle.
React polling example
import { useEffect, useState } from "react";
type Shipment = {
id: string;
status: string;
eta: string | null;
};
type ShipmentPage = {
items: Shipment[];
total: number;
updatedAt: string | null;
};
type FeedState = {
key: string;
data: ShipmentPage | null;
syncing: boolean;
error: string | null;
};
export function useShipmentFeed(
status: string,
page: number
) {
const key = `${status}:${page}`;
const [feed, setFeed] = useState<FeedState>({
key,
data: null,
syncing: true,
error: null,
});
useEffect(() => {
const controller = new AbortController();
let active = true;
let timer: ReturnType<typeof setTimeout>;
setFeed({
key,
data: null,
syncing: true,
error: null,
});
async function refresh() {
if (!active) return;
setFeed((previous) =>
previous.key === key
? { ...previous, syncing: true }
: previous
);
try {
const params = new URLSearchParams({
status,
page: String(page),
});
const response = await fetch(
`/api/shipments?${params}`,
{ signal: controller.signal }
);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data =
(await response.json()) as ShipmentPage;
if (active) {
setFeed({
key,
data,
syncing: false,
error: null,
});
}
} catch (error) {
if (active && !controller.signal.aborted) {
setFeed((previous) =>
previous.key === key
? {
...previous,
syncing: false,
error: "Could not refresh shipments",
}
: previous
);
}
} finally {
if (active) {
timer = setTimeout(refresh, 15000);
}
}
}
void refresh();
return () => {
active = false;
controller.abort();
clearTimeout(timer);
};
}, [key, status, page]);
return feed.key === key
? feed
: {
key,
data: null,
syncing: true,
error: null,
};
}
The endpoint, response shape, and 15-second interval are illustrative.
Several common problems here:
Requests follow the active query
The effect depends on the current filter and page. When either changes, React cleans up the previous effect and starts a new request process for the new query.
Old requests cannot update the new view
The cleanup aborts the previous fetch. The active check also prevents a response from updating component state after its effect has been cleaned up. That matters because network requests do not necessarily finish in the order they started. Imagine the user selects Delayed and immediately switches back to All.
The Delayed request may finish last. Its response should not replace the current All result.
Polling requests don't overlap
The next timer is scheduled after the current request settles. That prevents this particular polling loop from starting another request while its previous request is still pending. In a production application, I would also consider an upper limit on request duration.
Existing results remain available during a refresh
After the initial load succeeds, setting syncing to true doesn't clear the current response. The interface can continue showing its most recent results while the next request is in progress. This makes a useful distinction between initial loading and refreshing existing data.
For a real application, I would also consider request deduplication, visibility-aware polling, authentication expiration, retry backoff, and how multiple components share the same data. A dedicated server-state library may be appropriate when those responsibilities grow.
Give every refresh a predictable UI behavior
Fetching current data is only half the implementation. The frontend also needs rules for presenting that data. Consider a shipment with this information:
If the user is looking at that shipment, the interface should present the newer information in the relevant record. But the application must also decide what happens if the new status changes which rows match the active filter.
Suppose the current view shows only In transit shipments. After a refresh, a shipment becomes Delayed.
Should it disappear immediately?
There isn't one correct answer for every operational product. Immediate removal maintains strict consistency with the filter. It can also make a row disappear while someone is examining it.
An alternative interaction is to retain the currently displayed set temporarily and indicate that newer results are available.
The appropriate behavior depends on the task. For a monitoring dashboard, timely changes may take priority. For a table where operators select records and perform multi-step actions, preserving working context may be more important. This is a product decision that frontend and UX teams should agree on before implementation.
Treat timestamps as part of data reliability
A label such as "Last updated" sounds simple, but developers need to agree on what it represents.
- When the external carrier produced the information
- When the backend received the update
- When the API response was generated
- When the frontend successfully fetched the response
These times can differ. A successful API request at 12:30 doesn't prove that the carrier supplied new data at 12:30.
In LogiCore, shipment monitoring includes status information, ETA drift indicators, and last-update timestamps associated with refreshed carrier data.
For similar systems, I would make timestamp ownership explicit in the API contract. The response might distinguish:
{
"shipmentId": "SHP-2048",
"status": "delayed",
"eta": "2026-10-23T16:10:00Z",
"carrierUpdatedAt": "2026-10-23T12:24:00Z",
"fetchedAt": "2026-10-23T12:30:00Z"
}
It can show when the shipment information last changed and separately track when the application last synchronized.
This distinction becomes useful during outages, integration delays, and incidents where a user needs to know how current the information actually is.
A failed refresh should be visible
Imagine a dispatcher reviewing 40 shipments. The page initially loads correctly. Five minutes later, the network becomes unstable and the background refresh fails.
Should the table disappear?
Usually, the application still has a previously successful response that the user can inspect. The interface can preserve those records and show that the latest refresh failed. A useful set of UI states will include:
An expired session needs special handling. The product should not keep issuing the same unauthorized request every few seconds. If the request fails repeatedly, retry logic may need an increasing delay.
These are recommended patterns for operational software. Their exact implementation should follow the application's security requirements and data sensitivity.
Don't let background updates interrupt active work
There is another problem that appears when a product moves beyond read-only dashboards.
Suppose a user is editing the assigned carrier or reviewing a shipment exception while new information arrives from the backend. A refresh that blindly replaces an entire local object can overwrite in-progress changes.
For editable records, I would define which fields belong to the current server snapshot and which fields belong to an active local operation. The application also needs a policy for conflicts.
- Has the record changed since the user opened it?
- Is the user editing the same field that changed on the server?
- Can the operation be submitted against the newer record version?
- Does the user need to review the difference before continuing?
Depending on the product, that may require version fields, conditional updates, or a conflict-resolution interaction.
These decisions are especially important in ERP, CRM, warehouse management, and other B2B applications where multiple users work with related records. This is a general implementation consideration, not a claim about a specific LogiCore editing workflow.
What I would test before release
For a React interface with changing operational data, I would test scenarios beyond the happy path.
Rapid filter changes. Switch between filters while requests are pending. Confirm that the UI displays the result for the current query.
Slow responses. Simulate a request taking longer than the normal refresh interval.
Unmounting. Navigate away while a request is pending. Confirm that the polling process stops.
Repeated failures. Simulate network errors and authentication expiration. Check what remains visible and how the interface recovers.
Changing records. Update a shipment while a user is reviewing its details. Confirm that selection and navigation behave as intended.
Inactive tabs. Check whether background polling continues unnecessarily when the page isn't being used.
Different refresh speeds. Evaluate whether status changes appear frequently enough for the operational requirements and whether polling creates unnecessary backend traffic.
I would also review accessibility: refresh indicators should communicate meaningful changes, and frequent updates should not continuously interrupt screen-reader users.
What LogiCore taught us about operational frontend architecture
UITOP designed and developed LogiCore as one application connecting shipment monitoring, warehouse structures, inventory, and operational data.
The shipment interface uses React and TypeScript, with TanStack Table for table behavior and Zustand-managed polling for refreshed shipment information.
The backend uses Node.js, Express, PostgreSQL, Prisma, and REST APIs.
Those implementation decisions illustrate an important relationship between the frontend and its data sources. Users control filters, pagination, and navigation. The backend supplies current operational records. The request lifecycle determines when newer data reaches the interface.
A frontend architecture needs to manage those responsibilities consistently as the product grows. If I were reviewing an existing B2B SaaS product today, I would start with one practical scenario:
What happens when the user changes a filter at the same moment a background request returns newer data?
The answer reveals a lot about the quality of the application's state management, request handling, and user experience.
If you read this, read also about the project and the team
LogiCore is a logistics and warehouse SaaS designed and developed by UITOP, a product design and software development team specializing in complex B2B applications.
The project covered product design, frontend and backend development, database architecture, API development, and QA.
Read the LogiCore case study.


Top comments (1)
One case worth adding to the test list: the same dashboard open in two tabs. Each tab can run its own polling loop and keep separate filters and selections. Whether those views should stay independent is a product decision, but it's worth making explicitly.
Sharing UI state with BroadcastChannel is one option. Having one tab poll and relay results is another, but that adds coordination: what happens when that tab closes, and how does a newly opened tab get the current data?