urql
urql copied to clipboard
RFC: Fetch result callback function
Summary
Often times it is needed to initialize the client state based on server state. e.g forms and so on. Now that needs to be done in fragile and cubersome manner using useEffects etc. A callback for this purpose would be easier to understand and more straight forward from the library users prespective.
Proposed Solution
Would be more straight forward if urql would provide onResult
callback that would be called when the result is received, something like: useGetStuffQuery({onResult: (result) => setState(result.data.getStuff)});
Requirements
What is defined under a result? Is an error a result? What about an implicit refetch or a synchronous cache update?
Good questions! I'd say that the result is close to the same what here when the request has finished: const [result] = useGetStuffQuery(...)
. An error would also be a result. Either something like (result, error) => setState(result.data.getStuff)
or (result) => setState(result.error)
.
Maybe implicit updates could be flagged in the callback? Something like result.isImplicitUpdate
. Same with cache updates. Or then other way around that the first fetch is flagged with something like: result.initial
. Alternatively they could have their own callbacks if the separation would yield more robust apis and software. I'm interested in the very first results only so I'm not completely sure what use cases subsequent refetches etc could have in the case where you want to init something. Maybe there is none and the callback does not need to be called on subsequent results at all?
The thing is that there's not a single "moment" a result is given in urql
and that's a design choice. Rather, once you're dealing with the bindings you're dealing with state in the sense of reactivity; i.e. with a changing value that represents the client's state.
While it's possible in the exchanges to flag down "moments" (i.e. events) where new state is provided, that would be counter productive in bindings. In a lot of cases it can also lead you down rather dangerous paths where you're trying to basically derive a state machine from a non-exhaustive event. Having come from developing some stuff in observables, it's a recipe for chaos 😅
The reason why exchanges are basically the only place where events are exposed, all other places basically being some kind of "reduction" is because it "forces" you to deal with every possible state transition.
Let me give you some examples that illustrate this:
- When a result is cached you get a cache result instantly. Does this result in a synchronous result callback call? That would mean that a component isn't fully initialised (in all bindings). Since that's not desirable, should the call be deferred? If it is then your callback will never correspond with the time of other state changes.
- Should it be triggered when state changes? If so, what state? If it triggers whenever state changes then that's the same as observing updates in the bindings. How would it tell you about stale changes?
- Should it trigger on hydration? In
urql
, that's not a separate concept as bindings just "accept" results as they come.
Once you basically go all the way through these kinds of questions — and this list isn't exhaustive — you basically find that you'll have three answers:
- If you're dealing with UI state, the bindings give you just that; an idiomatic abstraction that binds
urql
's issuing of GraphQL data to your library of choice - If you're dealing with one off calls (which often either are mutations or one-off queries) then the promisified calls are fine
- If you're deriving state (basically building a state machine from GraphQL data changing, or integrating with UI state) then you find that these effects are best written in either another library or your UI libraries patterns
So, basically, from my point of view and IMHO, you're either trying to derive state but are looking to ignore certain state transitions, which may lead to other non-obvious problems like slight state transition bugs, or you're looking to maybe write it all in observables (which tbh leads to other problems in React which is why libraries like Jotai aren't trivial 😔)
From another perspective, the React the has chosen to make useEffect
the way it is (and other hooks for that matter) to not be a reaction (i.e. "do this if that") because it makes it easy to ignore relevant state changes. Hence it's a bit awkward, but that's how it leads you to not ignore relevant updates.
Obviously, what people then find is that if they model derived state in React then they often introduce updates that are accidentally deferred (i.e. don't happen immediately).
Sometimes, this can be fine. Other times, this can be a sign that the solution may be to separate the "input states" from an output state that is then computed (basically useState
+ external state like urql + useMemo
)
But to put it short, any update to result
in this hook is basically a valid state transition in urql
and "scoping it down" is often a more natural way to model this in React (and in other UI frameworks) than to "scope it up" (meaning, to manually pull in more updates)
Edit: to also clarify some of these points, if you want raw events then basically you can get them when thout the pitfalls by subscribing to the sources given by client.executeQuery
manually.
This will basically give you every kind of update without the pitfalls since it also exposes you to every possible state transition 😅
The reason why we basically have been annoyingly rejecting API additions like these outright is two fold though:
- We think that patterns should incrementally become more "prescriptive"; similar to React's API evolution in a way, but also related to our ambitions to eventually introduce more opinionated patterns around "the urql of today", towards what Relay is
- and; if there's one way to do things (which is objectively not cumbersome, i.e. either can be abstracted if it just feels annoying because it's sometimes a bit more code) but is best / least errorprone in the most common cases, then that's the only one possible way an API should work
In essence, for the last point, in a lot of cases (the separation between "core", exchanges, and bindings aside), independently, in urql
you may find that there's often only one way to do things. And if things feel off then sometimes I do firmly believe there's a better pattern that's already possible, either with the existing APIs or with your UI framework of choice (i.e. using the exisiting tooling more effectively) ❤️
Hope that clarifies this, but I'd be more than happy to explore this case more ✌️
Wow, thanks for the response! It never stops amaze me how thoroughly you answer to all the issues and ideas, it clearly tells how much you care and understand about the project.
I don't have any counter arguments to anything, I think you have much better understanding of all the possible pitfalls and corner cases in the idea I presented.
However, from the users perspective, initializing app state through urql is maybe one of the most cumbersome things DX wise I encounter regularly. It stands out since most of the other stuff with urql is so smooth. Usually it happens via useEffect and some checks. It's always kinda adhoc solution to the problem and feels slightly annoying when you "just need the data" and would want to move forward.
One good example where we actually shot our self into leg with that was with cache-and-network
and an 3rd party library that had to be initialized. It took a long time understanding why the library ends up into a bad state. The reason was that the library expected to only retrieve one set of initial parameters. At that point the library initialized it self, and when the second set of props arrived, it could not handle it anymore for what ever reason. The reason why the lib got two sets of parameters was since we first occasionally returned cached version first, then the new version when network request was done. For that specific query we moved to cache-first
approach which solved the issue but it was not easy to debug.
So if you think that onResponse
kind of callback is suboptimal solution, maybe there might be some robust pattern that could be documented? Or maybe even some small package in future to help with that? The spark for the idea was that with onResponse
(or onStateChange
or similar) it would be very explicit when it is called and that way easier to debug while the regular hook approach feels much more difficult and cumbersome in that kind of cases.
E: When thinking about the problem, I think most often it is that you want to detach your app state from server state for some time, but you often also want to bring them back together when the changes are done and mutation is finished. Like when editing a form for example. This does not mean that the actual urql state would need to be modified, but detached (copied?) version only. Maybe in a bit similar manner that with optimistic updates.
Sorry, I forgot to come back to this here. Basically, as we outlined before, this is a bit of a question between how bindings work and how the core Client itself works. But I'm still inclined to say that this is a case where we'd be exposing events, especially in React and Vue, where the timing of this callback would be either ambiguous and confusing or equivalent to effects 🤔
I'll close this for now, as I don't think we'll be picking this up. Sorry!
This feature would be very very great if it is possible to implement with URQL.
- More readable (Better DX)
- Save developers from boilerplates like
useEffect
and some conditional statements
Is now timing? Yes?