vlcn-orm
vlcn-orm copied to clipboard
experimental react suspense-based hooks
Opening this early with my WIP for discussion of the design and tradeoffs.
Tradeoff 1: how to provide the context promise
There are two ways to integrate Suspense for smoothing over the async
bootstrapping when integrating with React:
- Wrap the app in a Provider which provides the
Promise<Context>
. Hooks use that provided promise to suspend until the Context is initialized. - Provide a factory to create the hooks by passing the
Promise<Context>
. The created hooks can just reference the promise in scope.
I'm lately partial to the latter so I went with that for this prototype. Prior art in Liveblocks, similar conceptual API design in Stitches. It removes the Provider bloat and gives you more control over hook typings.
The suspense waterfall
Each hook first suspends on the Context being initialized. Then it suspends on the construction and initial execution of the provided Query. The goal of this 2-step suspension is that the component using the hook will always have a valid result to work with in scope, and you just rely on Suspense for all loading and initialization, rather than having to write conditional loading logic in every component using a hook.
That's the whole DX goal of Suspense imo - you can wrap your whole app in Suspense and never worry about loading states again until you later decide you want to create more granular boundaries around components with slower queries. It really provides a 'local data' feel.
Tradeoff 2: requiring cache keys
This one I didn't anticipate but turned out to be necessary. Because Suspense throws out of rendering a component when it hits a suspended value, and the thing I'm suspending on (a LiveResult
) is created inside the hook itself, it needs some kind of referentially stable key. Otherwise you get in a loop like:
- Run the query hook
- Create a LiveResult
- Suspend on the LiveResult's first execution
- Throw out the whole component (including hook scope, so including the LiveResult)
- The (now disposed) LiveResult resolves its first execution
- Suspense is now resolved, React re-renders suspended component
- Goto 1
So either the LiveResult must be cacheable by the Query that produces it, or a stable key is needed to reuse the previous steps 2-3.
So I made key
required for now. The key
has to change if deps change. Not great, feels very redundant and easy to break. Primary reason this is still very WIP.
Use of suspend-react
library
I'm not keen on adding dependencies, but this is a useful primitive for Suspense. It's possible to do it vanilla, but at this phase I figured it's better to flesh out the final design before optimizing.
Some changes to usage
useQuery
query creator now receives Context
as an argument. That makes it easy to do top-level queries.
I updated usage to now exclusively pull data from hooks, rather than passing the root list in from props (since that list is no longer available outside of React, it's being setup async). Feels more cohesive this way to me anyway. Plus the useQuery(TodoList.queryAll, ...)
syntax is nice and concise.
Query hook return value is now just the results. Results should always be populated with a valid resultset. Initialization is handled by Suspense. Errors should be propagated to an error boundary. There is no loading/revalidating state available, which is a potential for a future API, but I like the simplicity for now. Not sure how important a revalidating state is to UX, but it could be provided via a more verbose hook or even a separate hook just for that state (if results are cached centrally) like useRevalidating(TodoList.queryAll)
.
I also added a useQueryOne
which just pulls the first item from the resultset. Nothing fancy.
Gotchas
I ran into an issue using useSyncExternalStore
with LiveResult
- because LiveResult auto-pools itself when subscribers reaches 0, but useSyncExternalStore
always unsubscribes before subscribing again on a change. I think this auto-pool behavior may be too aggressive; it won't work so well with a useEffect
either. I might suggest moving the logic into a microtask or task handler with setImmediate
/queueMicrotask
to avoid that if it will work with your design.
I also used some ostensibly private properties like LiveResult.__currentHandle
(to suspend on initial query execution promise) and Model._d
(for non-referentially-equal data so that useSyncExternalStore
wouldn't memoize model changes). Perhaps these could be more officially exposed, as I think they both have advanced use cases which even end users may want to utilize.
Housekeeping
I regenerated the todo-mvc-memory example code since it seemed out of date.
Sorry I had missed this. Will take a look today.
I'm lately partial to the latter
Same. I've been bitten too often by context in the past where either
- someone wanted to reuse a component and didn't realize they needed some context
- someone modified a shared context in one project and broke a bunch of components on an unrelated project
The goal of this 2-step suspension is that the component using the hook will always have a valid result to work with in scope, and you just rely on Suspense for all loading and initialization, rather than having to write conditional loading logic in every component using a hook.
👍
This one I didn't anticipate but turned out to be necessary. Because Suspense throws out of rendering a component when it hits a suspended value, and the thing I'm suspending on (a LiveResult) is created inside the hook itself
Makes sense. A LiveResult
could compute a cache key for itself. One route would be to iterate over each expression in the plan
and have that expression return a string that represents it. Then concat all those strings together.
Another route would be to ask the plan to generate its SQL statement and use that (+ the stringified variables) as the key. The only gotcha here would be if someone did a where(lambda)
. Given the lambda is a function, stringifying it probably isn't ideal. Even if you did stringify a lambda and maybe hash it to condense the string size, you're missing any variables that might have been closed over :/
So I made key required for now. The key has to change if deps change
Sucks to surface this to the developer but it might be the only option -- unless we preclude lambda filters. Actually, maybe we can keep lambda filters. What if we cached the result of the part of the query that did not have lambdas applied and then always just re-applied lambdas? I think a caveat here is that the lambdas couldn't be async 😡
Anyway.. lets just move forward with a key. We can figure out automatic key generation in the future. Lot of other problems to solve.
It's possible to do it vanilla, but at this phase I figured it's better to flesh out the final design before optimizing
agreed 👍
because LiveResult auto-pools itself when subscribers reaches 0
This was a defensive move to prevent memory leaks but arguably it isn't required.
Looks gtm overall. Still draft?
I'll make some revisions and open it soon as I'm able
Can't wait to try this out on one of the apps I'm working on :)
And more complex cases (than the todo list) where components unmount and remount
@a-type - I had some ideas on expressing data fetches so we can avoid "fetch-on-render" here:
https://github.com/tantaman/vanilla-fetch
would love your feedback if you have some time.
I think suspense + the proposed paradigm could solve most of the loading problems.