Home > Mobile >  How to use react-query to replace context
How to use react-query to replace context

Time:12-04

I read an article on using react-query as a state manager and I am now trying to replace a context in my app with react-query (version 4).

Previously, I created a context with useContext() that stored a user account object for the logged-in user. I used react-query to fetch this data, and then useReducer() to modify the account object. However, I realized this is a mess and the relevant data is in react-query anyway, so I should get rid of the context and reducer and just use react-query directly (I am generating the user account object in the query that I make with react-query).

I generate the user account object in a custom hook:

function useUser(): UseQueryResult<User, Error> {
  const query = getInitialUserUrl;
  const platform = usePlatformContext();
  return useQuery<User, Error>(
    queryKeyUseUser,
    async () => {
      const data = await fetchAuth(query);
      if (didQueryReturnData(data)) {
        return new User(platform, data[0]);
      }
      return new User(platform);
    },
    {
      refetchOnReconnect: 'always',
      refetchInterval: false,
    },
  );
}

export default useUser;

Now I have a mutation where I verify the user's email address (old way using context):

const useMutationVerifyEmail = (): UseMutationResult<RpcResponseEmailVerify, Error, FormEmailVerifyInput> => {
  const { userObject } = useUserContext();

  return useMutation(
    (data: FormEmailVerifyInput) => verifyEmail(userObject.id, data.validation_code),
    },
  );
};

I tried to replace the call to context with a direct call to useUser():

const useMutationVerifyEmail = (): UseMutationResult<RpcResponseEmailVerify, Error, FormEmailVerifyInput> => {
  const { data: userObject } = useUser();

  return useMutation(
    (data: FormEmailVerifyInput) => verifyEmail(userObject.id, data.validation_code),
    },
  );
};

However, TypeScript complains that Object is possibly 'undefined' for userObject. I'm not sure why, because as far as I understand, useUser() always returns a User object.

How can I update my custom hook to return a User object so that I can use it instead of context?

UPDATE

I can wrap my useUser() hook in another hook:

function useUserObject() {
  const { data: userObject } = useUser();
  if (userObject instanceof User) {
    return userObject;
  }
  throw new Error('Failed to get account info!');
}

And then I can do const userObject = useUserObject() to get the user account object... but is this really the optimal way? Do I need to create a custom hook for a custom hook just to use my user objects like I do with useContext()?

CodePudding user response:

I wrote the article you've mentioned in your question.

It is not guaranteed that useQuery returns data when you call it, because data could not be fetched yet or fetching could have failed.

Now you might guarantee this in your app because you only call the hook when data has already been fetched - but TypeScript doesn't know that.

As stated at the end of the article, this is a tradeoff. What it gives you is that each hook is self-contained. If you call useMutationVerifyEmail in a place where you don't have a user object yet for whatever reason, it will go and try to fetch it.

There are various ways to "work around / with" that issue / limitation:

  1. You can ignore it and use a type assertion / the bang operator (!)
const useMutationVerifyEmail = (): UseMutationResult<RpcResponseEmailVerify, Error, FormEmailVerifyInput> => {
  const { data: userObject } = useUser();

  return useMutation(
    (data: FormEmailVerifyInput) => verifyEmail(userObject!.id, data.validation_code),
  );
};

This might err at runtime if you move the component that uses this hook to a place where a user isn't available, but it's fine if you know that this is the case :)

  1. You can check inside the mutationFn as an invariant, and make the mutation error if there is no user:
const useMutationVerifyEmail = (): UseMutationResult<RpcResponseEmailVerify, Error, FormEmailVerifyInput> => {
  const { data: userObject } = useUser();

  return useMutation(
    async (data: FormEmailVerifyInput) => {
      if (!userObject) {
        throw new Error("no user available")
      }
      return verifyEmail(userObject.id, data.validation_code)
    },
  );
};
  1. you can move the useUser call out of your custom mutation hook and just make the userId part of the input that is passed to the mutation.
const useMutationVerifyEmail = (): UseMutationResult<RpcResponseEmailVerify, Error, FormEmailVerifyInput> => {
  return useMutation(
    ({ id, ...data} : FormEmailVerifyInput & { id: number }) => verifyEmail(id, data.validation_code),
  );
};

Then, the component can call useQuery, maybe return null, a loading spinner or an error if there is no user, or you can disable the button that triggers the mutation as long as there is no user.

  1. You can take a step back and keep your context, but populate it with data from useQuery:
const UserProvider = ({ children }) => {
  const { data } = useUser()

  if (data.isLoading) return "loading ..."
  if (data.isError) return "error"

  return <UserContext.Provider value={data}>{children}</UserContext.Provider>
}

I might have to write about this pattern some more, because it is not state syncing / duplication. It is also not managing state with react context. All it does is distribute data from react-query via react-context. data is guaranteed to be defined when you read it through useContext(), so it takes away the undefined checks.

It will also always give you the "latest" data of react-query, because you still have your subscription via useQuery - just further up the tree. Context consumers will be notified of changes because the value updates.

Now there are tradeoffs to this as well, namely:

  • You cannot do partial subscriptions anymore via select, because react-context has no selectors.
  • If you do this with multiple Providers, you risk getting into waterfall fetches: fetching the user "blocks" the rest of the app, because it doesn't render children until the user is fetched. That's why user is defined when accessing it in children (which is desired), but it will also block all other fetches that would happen in the children.

I've done this before with something like an EssentialDataProvider. It triggers a bunch of useQuery calls, like user and stack related information that we need everywhere in the app - so it doesn't make sense to render the app without it. We then distribute it via context so that we don't have to null check it everywhere. We also prefetch these queries on the server.

It's a good tool to have at your disposal, but as everything, it has pros and cons :)

CodePudding user response:

The reason TS complains is because the types for react-query do make your user object undefined. In full details, the query has states:

export type QueryStatus = 'loading' | 'error' | 'success'

and it uses unions to, in short, make sure that when your query is in loading or error state (with the exception of refetch error), the data from the query is undefined, very simplified it looks like this:

type QueryObserverResult = {
  status: 'success',
  data: TData
} | {
  status: 'error',
  data: undefined
} | {
  status: 'loading',
  data: undefined
}

Since your use case is to use error boundary and rely on user being set, an approach here is to cast the useUser return value.

function useUser(): DefinedUseQueryResult<User, Error> {
  ...
  ...
  return useQuery(...) as DefinedUseQueryResult<User, Error>
}
  • Related