Is there a nicer way to handle isLoading checks in react?

59 Views Asked by At

In React we very often load data which is for some time undefined/null or some placeholder value. Now there are many ways to use loading skeletons, but in my project we have the skeletons rather low in the component hierarchy, so the code goes from this:

<div>
  <label>Name</label>
  <span>{user.firstname}</span>
  <span>{user.lastname}</span>
</div>
<div>
  <label>Street address</label>
  <span>{user.address.street}</span>
</div>

to something like this:

<div>
  <label>Name</label>
  <span>{isLoading ? <Skeleton/> : user.firstname}</span>
  <span>{isLoading ? <Skeleton/> : user.lastname}</span>
</div>
<div>
  <label>Street address</label>
  <span>{isLoading ? <Skeleton/> : user.address.street}</span>
</div>

so how can we avoid repetition here and drop the isLoading ternaries, but at the same time re-use the exsiting static jsx markup? Even better if we could just keep the original JSX and replace all nested props ie. user.address.street with a skeleton when user is loading.

1

There are 1 best solutions below

2
Andreas Herd On BEST ANSWER

ES6 proxies to the rescue. Let's write a recursive proxy that in case of user being null returns a recursive proxy. Let's also define a Loadable<T> type which has a isLoading property and returns either a JSX.Element or the original property.

type Placeholder<T> = {
  [K in keyof T]: T[K] extends Record<string, any> ? Placeholder<T[K]> : JSX.Element
}

export type Loadable<T> =
  | ({ isLoading: true } & Placeholder<T>)
  | ({ isLoading: false | undefined } & T)

so now Loadable is either loading and user.address.street is of type JSX.Element or it's loaded and then it's string or whatever it was in the first place.

Now let's write our recursive proxy (with a bit of ramda help, but you can easily drop the dependency):

export const placeholder = <T extends Record<string, any>>(
  target: T | undefined | null,
  placeholderElement: ReactElement
): Loadable<NonNullable<T>> => {
  const handler: ProxyHandler<T> = {
    get: (target: T, prop: string | symbol) => {
      if (prop === 'loading') {
        return isNil(target)
      }
      if (prop in placeholderElement) {
        return placeholderElement[prop as keyof ReactElement]
      }
      const value = target[prop as keyof T]
      if (anyPass([isNil, isObject, isArray])(value)) {
        return new Proxy(value ?? ({} as typeof value), handler)
      }
      return value
    },
    set: () => {
      console.warn('not implemented')
      return true
    }
  }
  return new Proxy(target ?? ({} as any), handler)
}

I will explain this in a bit but let's say we load a user somehow:

  const { data } = useQuery(() => loadUser()) // returns User | undefined type
  const user = placeholder(data, <Skeleton/>) // converts it to Loadable<User> which behaves almost like User but leaf-props are ambiguous.

  return <div>{user.address.street}</div> // type of street is JSX.Element | string

now when user is null the street property renders our <Skeleton/> and once user is loaded it will show the street name instead. So how does it do it?

Basically whenever we encounter a undefined value on the way down, we return an empty Proxy with a get handler, that does the following:

  • if property is isLoading return whether the source element is null
  • if the property exists in <Skeleton/> return that instead. This basically makes our Proxy object renderable in jsx.
  • For everything else we either either recurse (objects/array) or (for leaves) return the original property value.

There is even the option to use different loading markup, if one so wishes: <div>{user.isLoading ? 'Loading....' : user.address.street}</div>

I hope someone finds this useful. Of course the placeholder call should be probably wrapped into an own hook with useMemo(() => placeHolder(data, ...), [data]) to avoid recreating the proxy all the time.