-
-
Notifications
You must be signed in to change notification settings - Fork 3.6k
use discriminating union for query result type #1108
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
|
This pull request is being automatically deployed with Vercel (learn more). 🔍 Inspect: https://vercel.com/tannerlinsley/react-query/d550uxxjl |
|
Hi @MrLoh! There are a couple of reasons why they have been removed:
const { data, error } = useQuery('posts')
if (error) {
return <div>Error: {error}</div>
}
if (!data) {
return <div>Loading...</div>
}
return <div>Data: {data}</div> |
|
@boschni well it's just unfortunate to have the exposed types be subpar because of internal use of those types also casting does still provide some type safety, it will error if it is not castable to some degree. As to the suggested approach, this library just has the problem of providing 3 different ways to check on the status (status enum, boolean flags, and checking on the actual data). The docs suggest using the flags or status, but this is incompatible with typescript. If the way to use it is to just check on the data, than the flags and status should be removed. If this library wants to expose the status and the flags and wants to be typescript first, than it should also support using those with typescript without needing to do a non null assertion. The exposed types should not be worse because they are exported from the code. The extra 60 lines of type code shouldn't be a hindrance. |
|
If you think that checking on data and error is the right way of using it, then just deprecate the other fields and update the docs. As a consumer of this library I just don't want to think about this and the fact that the examples from the docs are not typescript compatible is confusing. |
|
I think you are addressing a valid point about the many ways results can be handled. In some examples the status property is used, in some examples the bools, and on the overview page it even uses a mixture of value checking and bool checking. I can imagine this could confuse people and possibly also lead to inconsistent codebases. Personally I think we should pick one direction and preferably the "safest" one. For the discussion about which one is safest I'll consider the status and bool methods equal. This way we can boil it down to "status" checking and "value" checking: Status checking: if (result.isSuccess) {
return <div>{result.data.name}</div>
}Value checking: if (data) {
return <div>{data.name}</div>
}When checking for the status, it could be that |
|
Status checking is true to the state machine internally. The data, error and other properties are more like state meta, not state definitions and should never really be relied on for exhaustive state checking. Technically, a successful query could have undefined data, and an unsuccessful query could throw undefined. At the end of the day, this would depend on your own types and generics. |
I think this is a very good point, and why data checking is insufficient imo. In the above example, we would show a loading spinner even though the query already completed, just because it yielded no data. The given example also only works if you check the fields in that order. Switch the
true, but I don't think we should optimize for that. People can still destruct if they want to, especially if you are a javascript user. Once you have more than one I would much rather keep the context of where things come from and not destruct: This is personal preference, for sure, but I think typescript users are used to not destruct if there is a discriminated union involved, because it doesn't narrow the type if you do :) |
This is true, but I think the order in which the fields are checked is also unique to every situation. For example, in some situations you might favor data above error. For example: when some data has already been fetched, but a background refetch failed, we might still want to show the data: const { data, error } = useQuery('posts')
if (data) {
return <div>Data: {data}</div>
}
if (error) {
return <div>Error: {error}</div>
}
return <div>Loading...</div>Or maybe a component is configured to not refetch automatically and every error is important: const { data, error } = useQuery('posts')
if (error) {
return <div>Error: {error}</div>
}
if (data) {
return <div>Data: {data}</div>
}
return <div>Loading...</div>Or if you want to be runtime resilient: const { data, error, isLoading } = useQuery('posts')
if (data) {
return <div>Data: {data}</div>
}
if (error) {
return <div>Error: {error}</div>
}
if (isLoading) {
return <div>Loading...</div>
}
return <div>Something is wrong here...</div>Just to be clear, I don't have a strong opinion on status vs value checking. But I think it's an interesting discussion to have. The main difference here is checking the actual data vs trusting what the state machine is indicating it should be. |
|
I also think it's an interesting discussion. As a typescript user, I do tend to trust the types, even to the level that I am willing to do exhaustive matching on discriminated unions: no default case needed if I match on all the possible cases, and I will even get a typescript error if react-query adds another state to the enum. Further, all your examples are still possible even if Maybe a bit off-topic, but why is |
|
Although all variations are possible, it would be interesting to see if we can pinpoint the advantages and disadvantages to each approach and maybe come to a preferred variation. Given the above examples, what would be the advantages of checking the state machine status above checking the data itself? In statically typed languages I would prefer the state machine status, but in such a dynamic browser environment I'm not fully sure. As to your second question, we could introduce a status like |
|
A lot of this goes back to state machine design too. The state tree is modeled something like this: {
{
[isIdle || status === 'idle'],
[isLoading || status === 'loading'],
[isError || status === 'error']: {
error, // the error
data, // latest known successful data
isFetching, // background fetching
isPreviousData,
failureCount,
},
[isSuccess || status === 'success']: {
data, // the data
isFetching // background fetching
isPreviousData
}
}The query can only be in one of those root states at once, and each root state determines what "meta" is available to the state. This list isn't exhaustive, but it illustrates that if only the met is used to determine state, then you'll immediately run into impossible combinations of meta. This is a pretty clear indicator to me that the status and safely derived booleans are the safest and easiest way of reliably detecting a query state. I have never and will never recommend to anyone to use the meta to exhaustively render a query's state. That said, there is nothing stopping people from doing it, this is Javascript at runtime after all. My suggestion:
|
|
This PR does also support boolean flags as discriminators. If status is the preferred way and this library wants to properly support typescript than discriminating unions are not optional IMO. Unless you think that the recommended usage pattern should be to use non null assertions in typescript. |
|
Let me otherwise list the disadvantages I see with status checking:
Would be great to get some counter arguments on these :) If we still decide to favor status checking, then I agree we should definitely provide discriminated unions. |
|
I thought the discussion was more about status booleans vs the status field 😅. Ad 1: Unless there is an error in the internal state machine, I don’t see how this can happen. If we can avoid casting and use the discriminated union internally, the types have to match. Ad 2: this is a really interesting point I never really thought about. I don’t see how this would be solvable with status checks unless we add more statuses (like Ad 3: which status would have an optional error? I assume idle can have optional data if the query was prefetched, and error can have optional data from the last successful fetch. |
|
Thanks for the feedback @TkDodo, let's try to wrap up the discussion.
Think most points have been made by now. Maybe just do a vote and then pick a direction? |
if that is true, than the PR is definitely wrong, as it states that loading never has data or error, see here also, I think we should get rid of the typecast here and build the currentResult depending on the disjoint union type. if we ensure correctness, I am still in favour of discriminated unions. I would also be happy to create a typescript example that leverages them for the docs once we have them :) |
|
Yeah the types would need to be updated to reflect the actual implementation. So what should be the recommended pattern in the docs and examples? Option 1 function Post {
const result = useQuery('post')
if (result.isLoading) {
return <div>Loading...</div>
}
if (result.isSuccess || (result.isError && result.data)) {
return <div>Title: {result.data.title}</div>
}
if (result.isError) {
return <div>Error: {result.error}</div>
}
return null
}Option 2 function Post {
const result = useQuery('post')
if (result.status === 'loading') {
return <div>Loading...</div>
}
if (result.status === 'success' || (result.status === 'error' && result.data)) {
return <div>Title: {result.data.title}</div>
}
if (result.status === 'error') {
return <div>Error: {result.error}</div>
}
return null
}Option 3 function Post {
const { data, error } = useQuery('post')
if (data) {
return <div>Title: {data.title}</div>
}
if (error) {
return <div>Error: {error}</div>
}
return <div>Loading...</div>
} |
Coming in from the sidelines here but what about representing the different statuses explicitly, something like: Would perhaps leave less room for confusion! 🙂 EDIT: IMHO, this |
|
@boschni according to @tannerlinsley post here, we should prefer the boolean in examples / documentation when possible. I would maybe add a So that would mean Option 1 to me, even though I think this is rather subjective: as it might hide errors of background refetches. Maybe you want to display them, maybe not. Is the error more important than showing stale data? Maybe, it it depends :) |
|
@MrLoh are you going to implement the suggested changes? please see here: #1108 (comment) otherwise, I don't think this PR is gonna make it :) |
|
Yes lets keep this discussion going :) I definitely agree that it depends on the use case, but value checking does make it more explicit. Depending on what is more important, you either first check for The default stale time configuration in React Query is very aggressive so by default there will be a lot of (background) refetching going on. This increases the risk of errors and users might get confused when they suddenly see errors popping up. There might not even be an obvious way for them to resolve the error. That is why I think it is important to focus more on "data" being available, instead of focussing on the status of the last (background) fetch. Value checking would be a solution for this, but it could also be solved on the query level. Like, if some query is refetched because of a new component mount, window focus or reconnect, and it fails, then just ignore it? |
|
Sorry I've been silent. I ended up going with the value checking approach and I am using error boundaries for errors, which is working fine for now. Unless there is a full agreement on the architecture and my bandwidth is also limited for implementation. I will not be able to build a solution that doesn't need casting |
|
I guess discriminated unions could work if we had more control on which things should be considered as errors. This can be done on the query level or on the observer level. Some ideas: Optional flag queryClient.fetchQuery(‘posts’, fetchPosts, { optional: true })
useQuery('posts', fetchPosts, { refetchOnWindowFocusOptional: true })Ignore certain errors in observers useQuery('posts', fetchPosts, { ignoreOnWindowFocusErrors: true })The upside of this approach is that each observer has the ability to determine for itself what should be considered an error, but the downside is that there is no way to indicate a fetch is optional when manually fetching with for example |
|
What about the inverse of that queryClient.fetchQuery(‘posts’, fetchPosts, { collectErrors: ['refetch', 'refocus'] })
useQuery('posts', fetchPosts, { collectErrors: ['refetch', 'refocus'] })Or different errors for each fetch type const { errors: { refetch, refocus } } = useQuery('posts', fetchPosts)Ahh, looks like @TkDodo already mentioned
|
|
I don't think we should add more flags. There are already a lot of flags, and having many flags can easily result in difficult to track state. Thinking more about this, I'm beginning to like the idea of an additional it would be breaking unless we keep I'm aware that |
|
I guess the state would then look something like this? With this change React Query assumes that errors which happened during refetches are less relevant, but it does give users the possibility to act on them if needed. It doesn't allow users to differentiate between the different type of refetches though, but maybe this level of granularity is not needed? |
I think this is a valid assumption.
Personally, I wouldn't need to know if a refetch happens because of window refocus or component mount or so ... maybe for debugging ... So, regarding your example: This would show a "background fetch error" state, right? And the only difference to a hard error would be that the query is in This would prioritise showing data, even if it's stale, over an error, which I think is good. The question is what implications this might have:
|
|
Correct! Error boundaries would not be triggered as the query is not in an error state and the isSuccess state should be explainable. The isSuccess state already is more of a “data is available” state as it is also used when there is placeholder data. @tannerlinsley what is your take on this? |
|
I'm not sure what I'm supposed to be commenting on TBH. There are a lot of differing opinions here... |
|
The problem we are trying to solve is how to deal with errors during refetches. Most libraries only fetch or refetch on explicit triggers. React Query however has the concept of staleness and window/reconnect refetches with aggressive defaults. Which means there will be a lot refetching going on without explicit triggers. This is fine in most cases because often we just want to show the most recent data, but because of the amount of refetches there is also a high chance that some of those refetches fail. And in my opinion, these optimistic refetches should not result in sudden error screens. These sudden errors could confuse users and they would probably not know what caused it or how to solve it. There are a couple of ways to solve this:
The outcome of this will also determine if discriminated unions should be added or not. |
|
has this been modeled out as a proper state machine? |
|
Modeling as a "proper" state machine wouldn't really improve the situation. You would still have to choose what you want your unique states to be and it would still result in the same questions here. What I am hearing in this discussion is that we are trying to achieve discriminated unions without hindering the flexibility to choose which root states are most important to our applications. Not every developer will agree on what those states are, and they really shouldn't, since it depends on the situation of each individual query, not an entire library. The idea has been thrown around to actually change the state machine (yes, it's a state machine, just not an xstate machine) to include many more top-level root states that represent all of the permutations of fetching, data, and error. That's a lot of permutations as root states compared to what we have right now; 4 possible root states (loading, success, error, idle) I originally modeled the documentation and api to account for the pattern that I saw the most:
If we were to enable discriminated unions, these are the states that would deem as both the majority use-case and also what we should continue to teach in the documentation. Any sub states that don't fit into the majority use-case can be derived with casting or other additional checks, right? |
|
I don't think the current defaults and recommendations are suited for most users. Let me give some examples:
These are just a few examples how applications would work by default when using React Query. The default stale time of 0 together with showing every background refetch error provides a sub-optimal user experience and it's an issue which is easily overlooked. Most other libraries don't have to deal with this as they do not automatically refetch that often. I like that RQ is trying to always display the most recent information, but I do think the default UX can be improved here. |
|
Okay that use case makes way more sense and is exactly what I was liking for in this discussion. So what I’m hearing is that there are really three base states. One idle, one loading and one settled. In the settled state, we could simultaneously have data, or an error or be fetching (along with a handful of other optional state context values that aren’t super important here)
```
isIdle
isLoading
isSettled(
error
data
isFetching
...others
)
```
Since data and error can happen simultaneously and also independent of each other, I don’t think they should be their own root states. This new format would be easy to template and also force you to handle the case where you want to display both the last known data and any potential errors. Obviously nothing is stopping users from doing this today, but the names just don’t make sense ;) Also, nothing is stopping you from keeping the current (flawed UX) flow either if you want to check in that order. So I think together with these base states we would need to come up with a new standard of teaching. Maybe as simple as this?
```
if (isIdle) return 'idle'
if (isLoading) return 'loading'
if (isSettled) {
return <>
{isLoading ? <Spinner /> : null}
{error ? error.message : null}
{data ? data.map(todo =>
<span>{todo}</span>
): null}
</>
}
```
The improvement really comes from the state ergonomics basically forcing you to handle both error and data simultaneously. If you opt out of either one, or do them serially, that's fine too, so long it affords/educates to do both.
Not sure how this affects discriminated unions though. Regardless though, this could be a good change of api and UX for the library and examples.
Tanner Linsley
…On Oct 29, 2020, 2:28 AM -0600, Niek Bosch ***@***.***>, wrote:
I don't think the current defaults and recommendations are suited for most users. Let me give some examples:
1. Say we use a hook to fetch data which is needed to show a page. The page renders fine and the user briefly switches tabs. The user comes back and starts reading, in the background a refetch is started and after a few seconds suddenly the entire page crashes. The user would have no clue about what happened or how to resolve it.
2. Within that same page there is a collapsed element which the user can click to display some additional information. This element is using the same hook. The user clicks it, the information is shown, but it also triggers a refetch in the background. After a few seconds the refetch fails and suddenly the entire page crashes. The user would again have no clue about what happened.
These are just a few examples how applications would work by default when using React Query. The default stale time of 0 together with showing every background refetch error provides a sub-optimal user experience and it's an issue which is easily overlooked. Most other libraries don't have to deal with this as they do not automatically refetch that often. I like that RQ is trying to always display the most recent information, but I do think the default UX can be improved here.
—
You are receiving this because you were mentioned.
Reply to this email directly, view it on GitHub, or unsubscribe.
|
|
Updated my comment ☝️ |
|
How would you want the I think this would also work out well for discriminated unions, see this playground link
I also just realised that if a query was once enabled, but is then being disabled, it will not go back to
Just for clarification, am I right that you wanted to check for I also like about this that this proposal would treat data and error equally, so people are not going to make the possible "mistake" of early returning when checking for isSuccess / isError anymore, because they just don't exist. You are settled, and you can render data and or error as you wish, if you have it (there is no guarantee). |
|
Oof 🤦♂️ yes I meant isFetching there. I think there might be some kind of isFetched or something. But at the end of the day, the user could just reference the enabled state itself right? |
Yes, you can just use the same condition that you passed to |
|
I like the idea of They'll probably just go with the most straightforward implementation: const { data, error, isLoading } = useQuery('post', getPost)
if (isLoading) {
return <div>Loading...</div>
}
if (error) {
return <div>Error: {error}</div>
}
return <div>Title: {data.title}</div>That is why it might also be useful to ignore refetch errors somehow. Like: const { data, error, isLoading } = useQuery('post', getPost, {
ignoreRefetchErrors: true // could be set as default?
})
if (isLoading) {
return <div>Loading...</div>
}
if (error) {
return <div>Error: {error}</div>
}
return <div>Title: {data.title}</div> |
|
Would this also eliminate |
|
Refetch errors getting hidden by default doesn’t seem like a safe default to me. I think it would confuse a lot of people if and when errors do happen and things don’t sync up like they expect. I still think we should prescribe handling either both simultaneously or at least errors first depending on the UX needed. That said, I’m not opposed to exploring some kind of optional mechanism to do what you’re referring to. Seems like it should be another issue/discussion though.
Tanner Linsley
…On Oct 30, 2020, 8:17 AM -0600, Kevin Sullivan ***@***.***>, wrote:
Would this also eliminate onSuccess and onError callbacks? Mirror the checks in onSettled?
—
You are receiving this because you were mentioned.
Reply to this email directly, view it on GitHub, or unsubscribe.
|
|
I've been experimenting a bit with the discriminated unions and I think we can get this working for everybody by adding two additional flags. This would be backwards compatible, and allows for easy handling of different type of errors: playground example |
|
Yeah that actually looks really good. I love the flexibility while still being very specific about the states. |
|
Created a PR: #1247 |
|
Closed with #1247 |
This PR adds back discriminated union types, I don't know where they create overhead and why they were removed, but I would argue that they are one of the few things that bring real benefits when using typescript.
#1102 (comment)
#922 (comment)