reactjstypescriptreact-routerremix.runreact-suspense

How do I properly type a deferred response in a Remix loader?


I'm working on learning Remix with a simple application that fetches clips from a Twitch channel and displays them in a table. I'm running into a weird issue with Typescript while trying to fetch this data from the Twitch API and am unsure how to proceed.

I have my Index component set up to use a deferred value from the loader (as described here: https://beta.reactrouter.com/en/dev/guides/deferred) and it is sort of working - the loader is fetching and returning the correct information and I can see the loading state showing up and then disappearing when the loading is complete. Hooray! But Typescript is complaining about the actual returned type of the data.

Here is the code that is causing me difficulties:

// _index.tsx
type ClipsResponse = {
    count: number;
    clips: ClipDto[];
}

export async function loader({ request }: LoaderArgs) {
    const url = new URL(request.url);
    const broadcasterName = url.searchParams.get("broadcasterName");
    // fetchClips is complicated but returns type Promise<ClipsResponse>
    return defer({ clipData: fetchClips(broadcasterName) });
}

export default function IndexRoute() {
    const data = useLoaderData<typeof loader>();
    return (
        <Suspense fallback={<LoadingSpinner />}>
            <Await
                resolve={data.clipData}
                errorElement={<ClipTableError broadcasterName="" />}
                children={(clipData) => (
                    <div
                        className={`flex flex-1 justify-center my-4 rounded-md bg-slate-50 border-slate-300 border h-full w-full`}
                    >
                        <ClipTable clips={clipData.clips} />
                    </div>
                )}
            />
        </Suspense>
    );
}
// ClipTable.tsx

type ClipTableProps = {
    clips: ClipDto[];
};

const ClipTable = ({ clips = [] }: ClipTableProps) => {
    // do stuff to display clips in table
}

The error that I'm getting is when trying to pass clipData.clips to the <ClipTable /> component; Typescript errors by saying:

Type 'SerializeObject<UndefinedToOptional<ClipDto>>[]' is not assignable to type 'ClipDto[]'.
  Type 'SerializeObject<UndefinedToOptional<ClipDto>>' is not assignable to type 'ClipDto'.

I don't necessarily mind having to potentially parse whatever the loader is doing to the data to serialize it, but the documentation doesn't say anything about this type and I'm not sure where it's coming from or how to process it.

Any help would be appreciated!


Solution

  • The useLoaderData hook doesn't work with generics and returns type unknown.

    See source:

    /**
     * Returns the loader data for the nearest ancestor Route loader
     */
    export function useLoaderData(): unknown {
      let state = useDataRouterState(DataRouterStateHook.UseLoaderData);
      let routeId = useCurrentRouteId(DataRouterStateHook.UseLoaderData);
    
      if (state.errors && state.errors[routeId] != null) {
        console.error(
          `You cannot \`useLoaderData\` in an errorElement (routeId: ${routeId})`
        );
        return undefined;
      }
      return state.loaderData[routeId];
    }
    

    You can cast the data to the correct type. Typically something like:

    const data = useLoaderData() as ClipsResponse;
    

    Things get a little more heavy when deferring the response though, as the these get wrapped in guarded Promises. Recast the resolved/deferred value back to unknown and then to your ClipsResponse type.

    I believe the following could be close to what you are looking for.

    type ClipsResponse = {
      count: number;
      clips: ClipDto[];
    }
    
    ...
    
    export default function IndexRoute() {
      const data = useLoaderData();
    
      return (
        <Suspense fallback={<LoadingSpinner />}>
          <Await
            resolve={data.clipData}
            errorElement={<ClipTableError broadcasterName="" />}
            children={(data) => {
              const clipData = data as unknown as ClipsResponse; // <-- cast here
              return (
                <div
                  className="flex fl...ll w-full"
                >
                  <ClipTable clips={clipData.clips} />
                </div>
              );
            }}
          />
        </Suspense>
      );
    }