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]
+}