How to infer the data type from the unserializer function return type in a React / Remix hook?

182 views Asked by At

We're building a pretty basic CRUD application with Remix. Our routes re-use loader data from their parent routes, so I created a custom hook, useTypedRouteLoaderData, which is pretty much equivalent to useLoaderData and useRouteLoaderData. The loader data also has to be unserialized before consumption.

This was my initial attempt. Worked, but quite verbose and ugly. Having to manually define the unserializer function in 20 something routes? Yeah, no.

const data = useTypedRouteLoaderData<typeof otherLoader>('routes/stuff');
const unserializer = (x: typeof data) => {
  const { stuff, popular: _unusedHere, ...rest } = x;

  return {
    ...rest,
    stuff: stuff.map((x) => unserialize<Stuff>(x)),
  };
};
const [stableData, update] = useState(unserializer(data));

useEffect(() => {
  if (data) {
    update(unserializer(data));
  }
}, [data]);

export function useTypedRouteLoaderData<T = any>(
  routeId: string) {
  const unstableRawData = useRouteLoaderData(routeId) as SerializeFrom<T>;

  return unstableRawData;
}

So in that example, stableData contains the correct types. stuff is what I expect it to be and TS will rightly complain about using stuff that doesn't exist.

In order to cut down on the boilerplate, I ended up doing this, which works, but rootData is of type any. I don't know what I expected, as I wrote U = any.

I want U to be whatever shape object the unserializer function returns. I just don't know how to do that.

// routes/somechildroute.tsx

// rootData is `any`
const [rootData, updateRootData] = useTypedRouteLoaderData<typeof rootLoader>(
  'root',
  ((x) => { // x is correctly typed
    return {
      ...x,
      user: x.user ? unserialize<User>(x.user) : null,
    };
  })
);

// hooks/useTypedRouteLoaderData.ts
import { useRouteLoaderData } from '@remix-run/react';
import { SerializeFrom } from '@remix-run/node';
import { useCallback, useState } from 'react';

export type Serialized<T = any> = SerializeFrom<T>;
export type UnserializerFn<T = any, U = any> = (data: Serialized<T>) => U;
export function useTypedRouteLoaderData<T = any, U = any>(
  routeId: string,
  unserializer: UnserializerFn<T, U>
) {
  // https://github.com/remix-run/remix/blob/a20ae7fb0727212ac52bdc687513c61851ac4014/packages/remix-react/components.tsx#L1000
  const unstableRawData = useRouteLoaderData(routeId) as SerializeFrom<T>;

  if (!unstableRawData) {
    if (routeId !== 'root' || !routeId.startsWith('routes')) {
      throw new Error(
        `Invalid routeId ${routeId}: it should be "root" or start with "routes".`
      );
    }

    throw new Error(
      `Could not find loader data for ${routeId}. Ensure that the id is correct, see useMatches().`
    );
  }

  const [data, setData] = useState(unserializer(unstableRawData));

  // This just updates it to the latest data from the loader.
  const updateData = useCallback(() => {
    setData(unserializer(unstableRawData));
  }, [unstableRawData]);

  return [data, updateData, unstableRawData];
}

Here's a reproduction: https://stackblitz.com/edit/remix-run-remix-62mwl8?file=app%2Froutes%2F_index.tsx,app%2Froot.tsx

0

There are 0 answers