diff --git a/docs/framework-integrations/react.mdx b/docs/framework-integrations/react.mdx index c4b0e04990..6be7bfd227 100644 --- a/docs/framework-integrations/react.mdx +++ b/docs/framework-integrations/react.mdx @@ -7,7 +7,7 @@ import TabItem from '@theme/TabItem'; # React -[React][] components for the Uppy UI plugins and a `useUppyState` hook. +[React][] components for the Uppy UI plugins and hooks. ## Install @@ -63,7 +63,7 @@ The following components are exported from `@uppy/react`: ### Hooks -`useUppyState(uppy, selector)` +#### `useUppyState(uppy, selector)` Use this hook when you need to access Uppy’s state reactively. Most of the times, this is needed if you are building a custom UI for Uppy in React. @@ -87,6 +87,26 @@ You can see all the values you can access on the type. If you are accessing plugin state, you would have to look at the types of the plugin. +#### `useUppyEvent(uppy, event, callback)` + +Listen to Uppy events in a React component. + +The first item in the array is an array of results from the event. Depending on +the event, that can be empty or have up to three values. The second item is a +function to clear the results. Values remain in state until the next event (if +that ever comes). Depending on your use case, you may want to keep the values in +state or clear the state after something else happenend. + +```ts +// IMPORTANT: passing an initializer function to prevent Uppy from being reinstantiated on every render. +const [uppy] = useState(() => new Uppy()); + +const [results, clearResults] = useUppyEvent(uppy, 'transloadit:result'); +const [stepName, result, assembly] = results; // strongly typed + +useUppyEvent(uppy, 'cancel-all', clearResults); +``` + ## Examples ### Example: basic component diff --git a/packages/@uppy/react/src/index.ts b/packages/@uppy/react/src/index.ts index e574256089..818c8db46f 100644 --- a/packages/@uppy/react/src/index.ts +++ b/packages/@uppy/react/src/index.ts @@ -5,3 +5,4 @@ export { default as ProgressBar } from './ProgressBar.ts' export { default as StatusBar } from './StatusBar.ts' export { default as FileInput } from './FileInput.ts' export { default as useUppyState } from './useUppyState.ts' +export { default as useUppyEvent } from './useUppyEvent.ts' diff --git a/packages/@uppy/react/src/useUppyEvent.test.ts b/packages/@uppy/react/src/useUppyEvent.test.ts new file mode 100644 index 0000000000..9dd45454bc --- /dev/null +++ b/packages/@uppy/react/src/useUppyEvent.test.ts @@ -0,0 +1,38 @@ +/* eslint-disable react/react-in-jsx-scope */ +/* eslint-disable import/no-extraneous-dependencies */ +import { describe, expect, expectTypeOf, it, vi } from 'vitest' +import { renderHook, act } from '@testing-library/react' + +import Uppy from '@uppy/core' +import type { Meta, UppyFile } from '@uppy/utils/lib/UppyFile' +import { useUppyEvent } from '.' + +describe('useUppyEvent', () => { + it('should return and update value with the correct type', () => { + const uppy = new Uppy() + const callback = vi.fn() + const { result, rerender } = renderHook(() => + useUppyEvent(uppy, 'file-added', callback), + ) + act(() => + uppy.addFile({ + source: 'vitest', + name: 'foo1.jpg', + type: 'image/jpeg', + data: new File(['foo1'], 'foo1.jpg', { type: 'image/jpeg' }), + }), + ) + expectTypeOf(result.current).toEqualTypeOf< + [[file: UppyFile>] | [], () => void] + >() + expect(result.current[0][0]!.name).toBe('foo1.jpg') + rerender() + expect(result.current[0][0]!.name).toBe('foo1.jpg') + act(() => result.current[1]()) + expectTypeOf(result.current).toEqualTypeOf< + [[file: UppyFile>] | [], () => void] + >() + expect(result.current[0]).toStrictEqual([]) + expect(callback).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/@uppy/react/src/useUppyEvent.ts b/packages/@uppy/react/src/useUppyEvent.ts new file mode 100644 index 0000000000..54f23a95c4 --- /dev/null +++ b/packages/@uppy/react/src/useUppyEvent.ts @@ -0,0 +1,38 @@ +import type { Uppy, UppyEventMap } from '@uppy/core' +import type { Meta, Body } from '@uppy/utils/lib/UppyFile' +import { useEffect, useState } from 'react' + +type EventResults< + M extends Meta, + B extends Body, + K extends keyof UppyEventMap, +> = Parameters[K]> + +export default function useUppyEvent< + M extends Meta, + B extends Body, + K extends keyof UppyEventMap, +>( + uppy: Uppy, + event: K, + callback?: (...args: EventResults) => void, +): [EventResults | [], () => void] { + const [result, setResult] = useState | []>([]) + const clear = () => setResult([]) + + useEffect(() => { + const handler = ((...args: EventResults) => { + setResult(args) + // eslint-disable-next-line node/no-callback-literal + callback?.(...args) + }) as UppyEventMap[K] + + uppy.on(event, handler) + + return function cleanup() { + uppy.off(event, handler) + } + }, [uppy, event, callback]) + + return [result, clear] +}