1

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.

Andreas Herd
  • 1,120
  • 9
  • 14

1 Answers1

-1

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.

Andreas Herd
  • 1,120
  • 9
  • 14
  • 1
    This is a really smart and elegant approach but sort of a mind bender. I feel that most people will find it really hard to go through the logic and understand its fundamental aspects due to the complexity added by the advanced TypeScript types (I immediately skipped the first part and went for the actual code) and ramda's methods. I believe, an ideal code walkthrough requires 2 steps. 1 in plain JavaScript accompanied by a brief explanation of isNil and anyPass, in order for the readers to understand what they're reading and a 2nd one with TypeScript in place. Thanks for sharing the insights! – Kostas Minaidis Aug 09 '23 at 11:47
  • @KostasMinaidis yeah that is the burden of anything more complex than hello-world. Of course the typescript types, as complex as they are, are solely here to guide the user without them having to fully grasp their inner-workings. I guess wrapping this into a library with a simple hook would do it's deed. Then one can just use it as is and let the IDE exposed types guide the dev. – Andreas Herd Aug 09 '23 at 11:54