Home > Back-end >  How to narrow types using React component
How to narrow types using React component

Time:08-26

I've noticed that a thing that happens a lot in my code is a ternary operation that narrows a type so I could use it inside the jsx, for example:

user: { name: { first: string, last: string } } | null;
...
return (
  <div>
    {user ? (
      <>
        <span>{user.name.first}</span>
        <span>{user.name.last}</span>
      </>
    ) : <LoadingSpinner />}
  </div>
);

I would like to do is make a component to replace this piece of code (that repeats itself in multiple places) and do the filter for me, something more like:

const Loading = ({ loading, children }: { loading?: boolean, children: React.ReactNode }) => {
  if (loading) return <LoadingSpinner />;
  return <>{children}</>
}
...
...
...
  return (
    <div>
      <Loading loading={!user}>
        <span>{user.name.first}</span>
        <span>{user.name.last}</span>
      </Loading>
    </div>
  )

the problem with this is that typescript doesn't consider this as a type narrower / type guard and will say that user can be null (and for a good reason, this actually might cause a bug if you assume the prop is not null / undefined, because the JSX children will throw error when user is null)

So my question is, is there a good concise way to create such a component that would both narrow the type somehow and protect me from errors (in a generic way, not just specifically for a certain prop or the other). So far the way I thought of requires a lot of function wrapping and props (using Required and NonNullable to be really safe and generic, but I'm just not sure it's worth replacing all of my occurrences for that, because it just might be longer

Be glad to hear your ideas!

Update 1:

Okay, I think I kind of figured out that what I want is not really possible, which is using a component as a type guard. I myself went for a bit more strongly typed solution, inspired by some of the comments here (Thanks guys!) which I'm adding here (keep in mind that it's not perfect, it was just suited for my usage)

type PartialOrNull<T> = {
  [K in keyof T] ?: T[K] | null;
};

type RequiredNonNull = {
  [P in keyof T]-?: NonNullable<T[P]>;
}

type RequiredRenderFunction<T> = (data: RequiredNotNull<T>) => React.ReactNode;

type NotRequiredRenderFunction<T> = (data?: T | null) => React.ReactNode;

interface LoadingProps<T extends object> {
  loading?: boolean;
  data?: T | PartialOrNull<T> | null;
  render: RequiredRenderFunction | NotRequiredRenderFunction;
  require?: boolean;
}

const notUndefinedOrNull = <V,>(value: V | null | undefined): value is V => !(value === null || value === undefined)

function Loading<T extends object>({ loading = false, data, render, require = false }: LoadingProps<T>) {
  if (loading) {
    return <LoadingSpinner />;
  }
  if (!require) {
    return (<>{(render as NotRequiredRenderFunction<T>)(data)}</>);
  }
  if (!data) return null;
  const values = Object.values(data);
  if (!values.length || values.filter(notUndefinedOrNull).length !== values.length) {
    return null;
  }
  return (<>{(render as RequiredRenderFunction<T>)(data as RequiredNotNull<T>)}</>);
}

And then the usage is:

interface User { 
  name: { 
    first: string; 
    last: string; 
  } 
};

const UserComponent = ({ user: User }) => {
  return (
    <>
      <span>{user.name.first}</span>
      <span>{user.name.last}</span>
    </>
  );
};
// ...
// ...
// ...
const App = () => {
  const user = User | null = useGetUser();
  return (
    <div>
      <Loading loading={!user} require data={{user}} render= {UserComponent} />
    </div>
  );
}

CodePudding user response:

You can use a generic Loading component with a render prop. I know it requires a bit of refactoring, but you get a reusable, generic Loading component:

import React from 'react'

const Loading = <T,>({ loading, render, data }: { loading?: boolean, data: T, render:(data: T) => React.ReactNode }) => {
  if (loading) return <div>loading</div>;
  return <>{render(data)}</>
}
interface User {
  name: {
    first: string;
    last: string
  }
}

const C = () =>{
  const user: User = {name:{first: 'rwithik', last: 'manoj'}}
  return (
    <div>
      <Loading loading={!user} data={user} 
        render={(data) => {
          return <><span>{data.name.first}</span>
        <span>{data.name.last}</span></>
        }}
      />
    </div>
}

You can even remove the loading props and just check if data is valid.

CodePudding user response:

Applying KISS, I would go for something simpler

Syntax Solution

First of all, maybe you want to move those few lines to a new component

const UserComponent = ({user}) => {
    return (
      <>
        <span>{user.name.first}</span>
        <span>{user.name.last}</span>
      </>)
}

And then, readability wise it would look like:

 <div>
    {user ? <UserComponent user={user}/> : <LoadingSpinner />}
 </div>

Another option could be

 <div>
    {!usser && <LoadingSpinner /> }
    {user && <UserComponent user={user}/>}
 </div>

I know it is not exactly building a new component, but maybe it is enough for your use case.

Using Suspense

Another option could be using Suspense, which looks like what you are trying to achieve. It was designed for fetch cases.

<Suspense fallback={<LoadingSpinner />}>
  <UserComponent />
</Suspense>

Let me know if it helped.

CodePudding user response:

You can use optional chaining with your original method:

<Loading loading={!user}>
    <span>{user?.name.first}</span>
    <span>{user?.name.last}</span>
</Loading>

Where Loading is defined as:

interface LoadingProps {
  loading?: boolean;
  children: React.ReactNode; // required since react 18
}

const Loading: React.FC<LoadingProps> = React.memo(({ loading = false, children }) => {
  if (loading)
    return <LoadingSpinner />;
  return <>{children}</>;;
})

Typescript shouldn't complain with this.

  • Related