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.