Why are these tuple types conjoined?

Viewed 182

I have recreated the error I'm getting in a brandnew Create-React-App repo, source code can be found here.

As you can see in MyContext.tsx, I'm simply adding the result of useState to my context. The type of the context is also defined as a tuple by

const UDContext = createContext<
  | undefined
  | [
      UserData | undefined,
      React.Dispatch<React.SetStateAction<UserData | undefined>>
    ]
>(undefined);

However, when I try to access the data in MyComponent.tsx by doing

  const [userData] = useUserData();

I'm getting the error

Property 'username' does not exist on type 'UserData | Dispatch<SetStateAction<UserData | undefined>>'.

This to me looks like it is somehow combining the tuple's types to one?

The funny thing: the below code does actually work (it does give a warning that context may be undefined, which indeed makes sense).

  const context = useUserData();
  const userData = context[0];

Exactly the same is happening in MySetter.tsx, where the exact error is

Not all constituents of type 'UserData | Dispatch<SetStateAction<UserData | undefined>>' are callable.

The above "solution" without direct destructuring works here as well.

The only way I can get this to work, is by explicitly declaring the type of the return value of useUserData.

A few sidenotes:

  • This is the @next version of Create-React-App
  • I had to enable --downlevelIteration, or else the destructuring of useUserData() was giving Type '[UserData | undefined, Dispatch<SetStateAction<UserData | undefined>>] | undefined' is not an array type or a string type. Use compiler option '--downlevelIteration' to allow iterating of iterators.
  • I have a working example in CodeSandbox that gives no issues at all.
2 Answers

This seems to be a BUG in typescript and I found two open bugs with similar issues BUG 31613 and BUG 40215

Apart from the option you mentioned in question (explicit type on return), another option would be to not use tuple destructuring and for your code it would look like:

MyComponent.tsx

import React from "react";
import { useUserData } from "./MyContext";

const MyComponent = () => {
  const userData = useUserData();

  if (!userData) {
    return <div>Loading username</div>;
  }

  return <div>{userData[0]?.username}</div>;
};

export default MyComponent;

MySetter.tsx

import { useEffect } from "react";
import { useUserData } from "./MyContext";

const MySetter = () => {
  const userData = useUserData();

  // Fakes an API call
  useEffect(() => {
    setTimeout(() => {
        userData && userData[1]({ username: "FooBar" });
    }, 750);
  });

  return null;
};

export default MySetter;

That's because your context can be also undefined so you need to make checks to make sure you always return something from your context. You could create reusable method for creating context thats always something, and not undefined:

function createCtx<T>() {
  const Context = React.createContext<T | undefined>(undefined);
  const useCtx = () => {
    const ctx = React.useContext(Context);
    if (typeof ctx === "undefined") {
      throw new Error("useContext must be inside provider with a value.");
    }
    return ctx;
  };
  return [useCtx, Context.Provider] as const;
};

Then use this method as such where you'd probably export useUserData and UserDataContext

const [useUserData, UserDataProvider] = createCtx<[
  UserData | undefined,
  React.Dispatch<React.SetStateAction<UserData | undefined>>
]>();

const UserDataContext: React.FC = ({ children }) => {
  const [userData, setUserData] = React.useState<UserData>();

  return (
    <UserDataProvider value={[userData, setUserData]}>
      {children}
    </UserDataProvider>
  );
};

and use it as such in your components

export default function App() {
  const [userData] = useUserData();
  
  return (
    <h2>{userData?.username}</h2>
  );
}

Not only does this solve your problem, but also gives you reusable method that helps you with other things that you'll surely need.

Cheers.

Related