My approach on managing state and API's for front-end apps
There could a lot of complexity when building front-end applications.
Starting from solutions that require a lot of boilerplate like redux-toolkit
,mobx
to simpler TanStack useQuery
like alternatives. Do you still remember flux
?
When building UI's we want to solve the following problems:
- • Show the data to the user as fast as possible. Ideally, if we can cache it on the client.
- • If the data is stale, we want to refresh it. Ideally if we can refresh only the stale and evicted records.
- • We want to to operate on the data quickly – it is a great user experience when pagination and filtering is instant.
- • I personally want to hide the data layer complexity behind a API.
So, how can we show the data to the user as fast as possible?
Let's imagine that we are building some sort of CRM. And there are invoices.
If you download all of the data to the client, you could filter and paginate it.
const data = useQuery('/api/invoices')
const [page, setPage] = useState(0)
const [filter, setFilter] = useState<FilterQuery>({})
const filtered = // imaginable filtering code
const paginated = // imaginable pagination code
// And render it
return paginated.map(invoice => <div>Invoice</div>)
That is a pretty decent solution. If the API itself is fast, then even if the response is a few megabytes it would still work fast. And a few megabytes is a lot of data.
Upside that you can paginate/filter on the client and it would feel instant.
The obvious downside that each time you come back you'd have to re-load all 10k records. And user would have to wait until the request completes to see the data.
You could potentially save the data in the localStorage
, so when user opens a page, they immediately see the stale data and some indicator that data is being refreshed. That is a somewhat better solution.
But there is quite a lot of data being passed back and forth. 🤷
You could utilise ETag
, but if the collection changes, you'd still have to reload everything.
If you update any of the invoices, you'd have to either redownload an individual invoice or the entire collection – depends on the application design. You could potentially utilise a fancy mutate
API's, where you update the data and also tell useQuery
to mutate data in cache. Which is pretty good.
But, what if your invoice somehow depends on the computed data. Let's say tax. You update tax on the invoice and the backend code has to re-calculate the final invoice amount. Meaning you mutated one value, but you need to update two values in the local cache: tax
and amount
.
You could still get away with that with a sort of Mutate/Redirect/Get pattern. That pattern is amazing. It made my life easier when I realised that I could design my API's that way.
However, if you show invoices in a few separate places – imagine a table with invoices and a modal that allows editing the invoice data. You update the tax in the modal, you need to update the amount
in the drawer and also in the table.
Which means that now you have to mutate data in two useQuery
hooks.
Something like redux
or mobx
or whatever is the latest and greatest thing solves that problem, because you have a single source of truth for the data. You could even normalise the records pretending to have a relational storage on the client.
That is my biggest issue with useQuery
, because I would never have a single source of truth for data. useQuery
has some amazing applications, but is not a silver bullet.
Anyhow, my point is – eventually, the complexity is getting too complex.
I want to describe my preferred approach.
There is a pattern called CQRS which basically separates how we read/mutate the data. I am following somewhat similar approach in my client apps.
For the state management I'm using rxjs
and dexie.js
. Dexie is a wrapper around IndexedDB – basically a KV database in the browser.
With rxjs
I just love how I can transform data in a stream. It's so expressive. Handling retries, handling request cancellations, handling batch data processing... RxJS is a king.
With my state management, I want to achieve the following.
I want my UI components to update when data changes. Data updates are happening inside of API methods using a single source of truth.
async function updateInvoiceTax(id: number, tax: number): Promise<void> {
// I also like rxjs to wrap API calls
return lastValueFrom(
fromFetch(`/api/invoice/${id}`, {
method: 'POST',
body: JSON.stringify({ tax })
}).pipe(
// A wrapper that checks if the request was successful, and then retries if necesssary
handleApiError(),
// Get the updated record
switchMap(() => fromFetch(`/api/invoice/${id}`)),
switchMap(response => response.json()),
switchMap(async invoice => {
// Update data in dexie
// That would intern trigger data observable – you'll see in later examples
database?.invoices.update(id, invoice)
}),
// Eventually return nothing, just to the types match
map(() => null)
)
)
}
Also, each of my collection API's always have id
and hash
field. hash
can be anything, but must change if the record body changes.
Basically, it looks like this
[
{ "id": 1, "hash": "d429", invoice_number: "AC-343", //... },
{ "id": 2, "hash": "a321", invoice_number: "AC-343", //... },
/// et cetera
]
// Hash is just a first four of the sha256(record)
hash := sha256(record)[:4]
For state management I'd create a rxjs
observable stream that reacts to changes in the dexie table and then pass it down to pagination, filters, etc.
All of the code samples are written from head and probably contain syntax errors. Treat is as a pseudocode 😄
// Types
type Query = {
amountMin?: number
amountMax?: number
dueDateMin?: Date
dueDateMax?: date
searchTerm?: string
}
// local query streams
export const query$ = new Subject<Query>()
export const partialQuery$ = new Subject<Query>()
export const page$ = new Subejct<number>()
// You can also orderBy$ and pretty much anything else.
// Effects
// This little snippet takes the latest object in query$ and overrides it with the fields from partialQuery$
// So if want to reset the filter, I'd write to query$, but if I want to change just one field I'd write to partialQuery$
partialQuery$
.pipe(
withLatestFrom(query$),
map(([partialQuery, currentQuery]) => ({
...currentQuery,
...partialQuery
})),
)
.subscribe(query => {
query$.next(query)
})
// Data streams
// There are a few things to unpack
//
// I am subscribing to changes on invoices table
//
// I also noticed that if you write to dexie, dexie would emit an event. Even if the actual data did not change
//
// For example, if you update a record, but the data is the same
// database?.invoices.update(id, data)
//
// dexie would still emit a change event, basically forcing the UI to re-render unnecessarily
//
// Therefore I pipe it to distinctUntilChanged(isEqual)
//
// isEqual could be any function that deep compares two objects and returns boolean on whether it changed or not
//
// I'm using react-fast-compare
export const data$ = from(
liveQuery(() => database?.invoices.toArray() ?? [])
).pipe(distinctUntilChanged(isEqual))
// Now, when I have the collection of items, I can apply filters, pagination, etc.
// For example
//
// I have a helper function that takes latest data$, latest query$ and an array of callbacks to help me filter each record individually
//
// createFilterStream does batching and debouncing internally, so I can shovel a large array of 100k records and my UI would still be fine
//
// Look at rxjs asapScheduler, from operator, and batch
//
// You can tell rxjs to process the large collection at smaller chunks only when browser is not busy
const withQuery$ = createFilterStream(data$, query$, [
// Filters
(item, query): bool => {
// If filter is not set, then return an item
if (!query.amountMin) {
return true
}
return query?.amountMin >= item.amount
},
(item, query): bool => {
if (!query.amountMax) {
return true
}
return query?.amountMax <= item.amount
},
(item, query): bool => {
if (!query.searchTerm || query.searchTerm === '') {
return true
}
// You can implement all sorts of searching, including fuzzy matching
return invoice.invoice_number.toLowerCase().includes(
query.searchTerm.toLowerCase()
)
}
// et cetera
])
// Apply pagination
const perPage = 10
const withPagination$ = combineLatest([withQuery$, page$]).pipe(
map(([collection, page]) => {
return colleciton.slice(page * perPage, perPage)
})
)
// You can have even more derivaties
// I prefer to re-export it as collection$, so I always have the same API
export const collection$ = withPagination$
// And because you have all of the data locally, you can calculate on aggregations
export const averageAmount$ = $data.pipe(
map(collection => {
if (collection.length === 0) {
return 0
}
const sum = collection.map(e => e.amount)
return sum / collection.length
})
)
In the component, code would look similar to this
const Component: React.FC = () => {
const collection = useObservable(invoices.collection$, [])
const average = useObservable(invoices.averageAmount$)
// Set an empty query and first page
useEffect(() => {
invoices.query$.next({})
invoices.page$.next(0)
}, [])
return <>
<Filters
query={query}
onQueryChange={nextQuery => {
invoices.partialQuery$.next({})
}}
/>
<Kpi average={average} />
<Table collection={collection} />
</>
}
We have a way to read from dexie – now we need something to read from the API and write to db.
I would usually have a few more subjects and a subscription that reacts to load requests, like so
// API control streams
export const load$ = new Subject<void>()
export const isLoading$ = new Subject<boolean>()
export const error$ = new Subject<Error|null>()
// Effects
load$.pipe(
tap(() => {
isLoading$.next(true)
error$.next(null)
}),
switchMap(() => fromFetch('/api/invoices').pipe(
// handle api error could be a custom rxjs operator that checks for errors, attempts retries if necessary
// it can also emit error in some sort of global error stream of into toast
//
// I have a global error stream and an effect that aggregates multiple errors together over a 3s time window
//
// then it shows a single toast to the user
handleApiError(error$),
switchMap(response => response.json())
)),
switchMap(await collection => {
database?.invoices.bulkPut(collection)
}),
tap(() => {
isLoading$.next(false)
})
).subscribe()
Now, each time when the page mounts, I can load the data from the API, provide default filter and set first page.
useEffect(() => {
invoices.load$.next()
invoices.query$.next({})
invoices.page$.next(0)
}, [])
An observable reader may ask "But wait, you still load the entire collection each time you open a page?"
There is a twist. Remember earlier I mentioned hash
? My collection API endpoints are actually not GET
, but POST
and accept the body looks like the following:
[[1, "d429"], [2, "x9s2"], ...]
Before I call fromFetch
I accumulate a list of known to the front-end ID/Hash pairs and send it in the body.
The API server checks which records were updated, evicted and created and returns a response according to the following rules:
- • If the object was evicted, it would return a object without hash or data, but with ID
- •
{"id": 1}
- •
- • If the object was updated or created, it would return a full entry with a new hash
- •
{ "id": 2, "hash": "4324", invoice_number: "AC-341", "amount": 3.14 }
- •
- • And if the data did not change, it would not return it at all.
Basically that gives me a ETag
like functionality on the individual record level.
Now, before writing data to dexie, I need to find all the records that do not have hash and remove them from local cache.
And then simply bulkUpdate
the remaining data.
When I update or mutate the data, I basically either follow Mutate/Redirect/Get pattern or simply update data in dexie and call it a day.
This gives me a lot of control over how I cache the data on the server and return responses almost instantly, while preserving a nice stale-while-revalidate strategy on the front-end.