microsoft / use-disposable

Creating instances in React during render that need to be disposed even with StrictMode
MIT License
10 stars 3 forks source link

A new freely usable implementation of use-disposable #26

Open tangye1234 opened 8 months ago

tangye1234 commented 8 months ago

After using the Disposable mode, I was unable to completely resolve the issues with React 18 because it requires creating the disposable instance during the React rendering phase, where lifecycle management isn't as robust as within the useEffect hook.

However, I've now developed a truly versatile useDisposable hook that can be seamlessly integrated into any scenario. Check out this demonstration:

import {
  DependencyList,
  EffectCallback,
  useEffect,
  useMemo,
  useRef
} from 'react'

type IDisposable = () => void

import { useIsStrictMode } from './useIsStrictMode'

const queue: (fn: () => void) => void =
  typeof queueMicrotask === 'function'
    ? queueMicrotask
    : (cb: () => void) => void Promise.resolve().then(cb)

const useStrictEffect = (effect: EffectCallback) => {
  const canDisposeRef = useRef(false)

  useEffect(() => {
    const dispose = effect()
    canDisposeRef.current = false

    if (dispose) {
      return () => {
        canDisposeRef.current = true
        queue(() => {
          if (!canDisposeRef.current) return
          dispose()
        })
      }
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [])
}

// eslint-disable-next-line react-hooks/exhaustive-deps
const useFreeEffect = (effect: EffectCallback) => useEffect(effect, [])

/**
 * Get or create an instance which is automatically disposed by this hook.
 *
 * @param create an instance factory which return instance and dispose pairs
 * @param deps when to create a new instance
 * @returns a managed instance
 */
export default function useDisposable<T>(
  create: () => readonly [T, IDisposable],
  deps: DependencyList = []
): T {
  const ref = useRef<IDisposable>()

  const isStrictMode = useIsStrictMode()
  const instance = useMemo(() => {
    if (ref.current) {
      // firstly dispose the last instance before create
      ref.current()
    }
    const [d, dispose] = create()
    if (typeof window === 'undefined') {
      // dispose should be invoked upon the server side
      queue(() => dispose())
    } else {
      ref.current = dispose
    }
    return d
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, deps)

  const useEffect = isStrictMode ? useStrictEffect : useFreeEffect
  useEffect(() => {
    const dispose = ref.current
    if (!dispose) return

    return () => {
      dispose()
      ref.current = undefined
    }
  })

  return instance
}

export { useDisposable }

Now when I use like this:

'use client'

import { useDisposable } from 'use-disposable' 

let a = 0
let b = 0

export function ClientComponent() {
  useDisposable(() => [console.log('create a:', ++a), () => console.log('revoke a:', a)] as const)
  useDisposable(() => [console.log('create b:', ++b), () => console.log('revoke b:', b)] as const)
  return null
}

in dev strict mode of react 18, browser console prints:

create a: 1
revoke a: 1
create a: 2
create b: 1
revoke b: 1
create b: 2

server console prints:

create a: 1
create b: 1
revoke a: 1
revoke b: 1

and if I unmount the component, the browser console prints:

revoke a: 2
revoke b: 2

So I think we should replace this method instead.

ling1726 commented 8 months ago

Thanks for the issue @tangye1234. Do I understand correctly that the difference in approach is that there the microtask would only be run after the double renders have been committed?

I like that the implementation no longer has a dependency on global WeakSet which means that multiple calls of useDisposable could be called. We've worked with microtasks before in Fluent for debouncing https://github.com/microsoft/fluentui/blob/master/packages/react-components/priority-overflow/src/debounce.ts.

One of the pitfalls of microtasks is that they operate outside of the effect cycle - which means that in userland there's no way to know when dispose has happened. It can also play badly with unit testing frameworks since disposal can occur after the the test has unmounted. See specifically here for the caveat that we added for unit test environments https://github.com/microsoft/fluentui/blob/ae3b77e7995d2ed37ae8d153308f82b4f3940e03/packages/react-components/priority-overflow/src/debounce.ts#L10-L14

Would you like to make a PR contribution to this repo and we could try to release a pre-release version to test it out for a while?