10

I'm using a few third-party React hook libraries that aren't required for the initial render. E.g. react-use-gesture, react-spring, and react-hook-form. They all provide interactivity, which can wait until after the UI is rendered. I want to dynamically load these using Webpack's codesplitting (i.e. import()) after I render my component.

However, I can't stub out a React hook because it's essentially a conditional hook, which React doesn't support.

The 2 solutions that I can think of are:

  1. Somehow extract the hook into a component and use composition
  2. Force React to reconstruct a component after the hook loads

Both solutions seem hacky and it's likely that future engineers will mess it up. Are there better solutions for this?

4
  • 1
    As you mentioned yourself, conditionally importing hooks breaks the first rule of React hooks. This is because the behaviour is unpredictable on successive renders and I cannot see how you would be able to achieve your goal. Looking at react-hook-form as an example, you would not have access to handleSubmit which is required on each render. Explanation: reactjs.org/docs/hooks-rules.html#explanation Commented Aug 12, 2020 at 10:53
  • I did this with a hook using functionality from framer-motion before. I essentially exposed what should be imported from framer-motion, a state which tells the hook user if the module is ready, and a function to load framer-motion module. My component will check if the module is ready or not before using the module exposed from the hook. It's not exactly a robust solution, and it requires you to manually handle the cases when the module isn't ready yet. Commented Aug 13, 2020 at 5:45
  • We use the first method all the time, it's so straightforward. You simply render a component conditionally which has the hook at its top level. Commented Aug 14, 2020 at 15:32
  • What you are talking about sounds like something I've heard called the sidecar pattern github.com/theKashey/use-sidecar Commented Aug 18, 2020 at 20:37

2 Answers 2

15
+50

As you say it, there are two ways to go about using lazy loaded hooks:

  1. Load library in a Parent Component, conditionally render Component using library when available

Something along the lines of

let lib
const loadLib = () => {...}

const Component = () => {
  const {...hooks} = lib
  ...
}

const Parent = () => {
  const [loaded, setLoaded] = useState(false)
  useEffect(() => loadComponent().then(() => setLoaded(true)), [])
  return loaded && <Component/>
}

This method is indeed a little hacky and a lot of manual work for each library

  1. Start loading a component using the hook, fail, reconstruct the component when the hook is loaded

This can be streamlined with the help of React.Suspense

<Suspense fallback={"Loading..."}>
  <ComponentWithLazyHook/>
</Suspense>

Suspense works similar to Error Boundary like follows:

  1. Component throws a Promise during rendering (via React.lazy or manually)
  2. Suspense catches that Promise and renders Fallback
  3. Promise resolves
  4. Suspense re-renders the component

This way is likely to get more popular when Suspense for Data Fetching matures from experimental phase.

But for our purposes of loading a library once, and likely caching the result, a simple implementation of data fetching can do the trick

const cache = {}
const errorsCache = {}
// <Suspense> catches the thrown promise
// and rerenders children when promise resolves
export const useSuspense = (importPromise, cacheKey) => {
  const cachedModule = cache[cacheKey]
  // already loaded previously
  if (cachedModule) return cachedModule

  //prevents import() loop on failed imports
  if (errorsCache[cacheKey]) throw errorsCache[cacheKey]

  // gets caught by Suspense
  throw importPromise
    .then((mod) => (cache[cacheKey] = mod))
    .catch((err) => {
      errorsCache[cacheKey] = err
    })
};

const SuspendedComp = () => {
  const { useForm } = useSuspense(import("react-hook-form"), "react-hook-form")
  const { register, handleSubmit, watch, errors } = useForm()
  ...
}

...

<Suspense fallback={null}>
  <SuspendedComp/>
</Suspense>

You can see a sample implementation here.

Edit:

As I was writing the example in codesandbox, it completely escaped me that dependency resolution will behave differently than locally in webpack.

Webpack import() can't handle completely dynamic paths like import(importPath). It must have import('react-hook-form') somewhere statically, to create a chunk at build time.

So we must write import('react-hook-form') ourselves and also provide the importPath = 'react-hook-form' to use as a cache key.

I updated the codesanbox example to one that works with webpack, the old example, which won't work locally, can be found here

Sign up to request clarification or add additional context in comments.

Comments

0

Have you considered stubbing the hooks? We used something similar to async load a large lib, but it was not a hook, so YMMV.

// init with stub
let _useDrag = () => undefined;

// load the actual implementation asynchronously
import('react-use-gesture').then(({useDrag}) => _useDrag = useDrag);

export asyncUseDrag = (cb) => _useDrag(cb)

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.