Repository: effector/effector
Pull request #425 (2020-11-21 13:07:06)

add support of object/array of events for `useEvent`
```tsx
import React from "react";
import { useEvent } from "effector-react/ssr";

import { emailChanged, passwordChanged } from "./model";

export function ExampleComponent() {
  const handlers = useEvent({ emailChanged, passwordChanged });

  return (
    <div>
      <input onChange={handlers.emailChanged} />
      <input onChange={handlers.passwordChanged} />
    </div>
  );
}
```

---

```tsx
import React from "react";
import { useEvent } from "effector-react/ssr";

import { emailChanged, passwordChanged } from "./model";

export function ExampleComponent() {
  const [email, password] = useEvent([emailChanged, passwordChanged]);

  return (
    <div>
      <input onChange={email} />
      <input onChange={password} />
    </div>
  );
}
```



Patch:
diff --git a/docs/api/effector-react/useEvent.md b/docs/api/effector-react/useEvent.md
index a2d0190f..36d0d61b 100644
--- a/docs/api/effector-react/useEvent.md
+++ b/docs/api/effector-react/useEvent.md
@@ -48,3 +48,52 @@ ReactDOM.render(
 ```
 
 [Try it](https://share.effector.dev/GyiJvLdo)
+
+## `useEvent(shape)`
+
+**Arguments**
+
+1. `shape` Object or array of ([_Event_](../effector/Event.md) or [_Effect_](../effector/Effect.md)): Events or effects as values which will be bound to the current `scope`
+
+**Returns**
+
+(Object or Array): List of functions with the same names or keys as argument to pass to event handlers. Will trigger given unit in current scope
+
+### Example
+
+```jsx
+import ReactDOM from 'react-dom'
+import {createDomain, fork} from 'effector'
+import {useStore, useEvent, Provider} from 'effector-react/ssr'
+
+const app = createDomain()
+
+const inc = app.createEvent()
+const dec = app.createEvent()
+const $count = app
+  .createStore(0)
+  .on(inc, x => x + 1)
+  .on(dec, x => x - 1)
+
+const App = () => {
+  const count = useStore($count)
+  const handler = useEvent({inc, dec})
+  // or
+  const [a, b] = useEvent([inc, dec])
+  return (
+    <>
+      <p>Count: {count}</p>
+      <button onClick={() => handler.inc()}>increment</button>
+      <button onClick={() => handler.dec()}>decrement</button>
+    </>
+  )
+}
+
+const scope = fork(app)
+ReactDOM.render(
+  <Provider value={scope}>
+    <App />
+  </Provider>,
+  document.getElementById('root'),
+)
+```
diff --git a/packages/effector-react/index.d.ts b/packages/effector-react/index.d.ts
index 55334a1c..9086f7c8 100644
--- a/packages/effector-react/index.d.ts
+++ b/packages/effector-react/index.d.ts
@@ -92,3 +92,23 @@ export function useEvent<T>(event: Event<T>): (payload: T) => T
 export function useEvent<T, R>(
   fx: Effect<T, R, any>,
 ): (payload: T) => Promise<R>
+export function useEvent<List extends (Event<any> | Effect<any, any>)[]>(
+  list: [...List],
+): {
+  [Key in keyof List]: List[Key] extends Event<infer T>
+    ? (payload: T) => T
+    : List[Key] extends Effect<infer P, infer D, any>
+    ? (payload: P) => Promise<D>
+    : never
+}
+export function useEvent<
+  Shape extends Record<string, Event<any> | Effect<any, any, any>>
+>(
+  shape: Shape,
+): {
+  [Key in keyof Shape]: Shape[Key] extends Event<infer T>
+    ? (payload: T) => T
+    : Shape[Key] extends Effect<infer P, infer D, any>
+    ? (payload: P) => Promise<D>
+    : never
+}
diff --git a/packages/effector-react/ssr.d.ts b/packages/effector-react/ssr.d.ts
index d382f6ab..a4ee285b 100644
--- a/packages/effector-react/ssr.d.ts
+++ b/packages/effector-react/ssr.d.ts
@@ -14,3 +14,23 @@ export function useEvent<T>(event: Event<T>): (payload: T) => T
 export function useEvent<T, R>(
   fx: Effect<T, R, any>,
 ): (payload: T) => Promise<R>
+export function useEvent<List extends (Event<any> | Effect<any, any>)[]>(
+  list: [...List],
+): {
+  [Key in keyof List]: List[Key] extends Event<infer T>
+    ? (payload: T) => T
+    : List[Key] extends Effect<infer P, infer D, any>
+    ? (payload: P) => Promise<D>
+    : never
+}
+export function useEvent<
+  Shape extends Record<string, Event<any> | Effect<any, any, any>>
+>(
+  shape: Shape,
+): {
+  [Key in keyof Shape]: Shape[Key] extends Event<infer T>
+    ? (payload: T) => T
+    : Shape[Key] extends Effect<infer P, infer D, any>
+    ? (payload: P) => Promise<D>
+    : never
+}
diff --git a/src/react/ssr.ts b/src/react/ssr.ts
index ec94ffb4..17bb2361 100644
--- a/src/react/ssr.ts
+++ b/src/react/ssr.ts
@@ -99,25 +99,40 @@ export function useStoreMap({store, keys, fn}: any) {
   })
 }
 
-/**
-bind event to scope
-
-works like React.useCallback, but for scopes
-*/
-export function useEvent(event: any) {
-  const scope = React.useContext(Scope) as any
-  const unit = scope.find(event)
-  const result = is.effect(event)
+function resolveUnit(unit: any, scope: any) {
+  const localUnit = scope.find(unit)
+  return is.effect(unit)
     ? (params: any) => {
         const req = createDefer()
         //@ts-ignore
-        launch({target: unit, params: {params, req}, forkPage: scope})
+        launch({target: localUnit, params: {params, req}, forkPage: scope})
         return req.req
       }
     : (payload: any) => {
         //@ts-ignore
-        launch({target: unit, params: payload, forkPage: scope})
+        launch({target: localUnit, params: payload, forkPage: scope})
         return payload
       }
-  return React.useCallback(result, [scope, event])
+}
+
+/**
+bind event to scope
+
+works like React.useCallback, but for scopes
+*/
+export function useEvent(eventObject: any) {
+  const scope = React.useContext(Scope) as any
+  const isShape = !is.unit(eventObject) && typeof eventObject === 'object'
+  const events = isShape ? eventObject : {event: eventObject}
+
+  return React.useMemo(() => {
+    if (is.unit(eventObject)) {
+      return resolveUnit(eventObject, scope)
+    }
+    const shape = Array.isArray(eventObject) ? [] : ({} as any)
+    for (const key in eventObject) {
+      shape[key] = resolveUnit(eventObject[key], scope)
+    }
+    return shape
+  }, [scope, ...Object.keys(events), ...Object.values(events)])
 }


Test patch:
diff --git a/src/react/__tests__/scopes.test.tsx b/src/react/__tests__/scopes.test.tsx
index 5c434a29..67db325e 100644
--- a/src/react/__tests__/scopes.test.tsx
+++ b/src/react/__tests__/scopes.test.tsx
@@ -277,8 +277,8 @@ test('computed values support', async () => {
     .on(fetchUser.done, (_, {result}) => result.friends)
   const friendsTotal = friends.map(list => list.length)
 
-  const Total = () => <small>Total: {useStore(friendsTotal)}</small>
-  const User = () => <b>User: {useStore(name)}</b>
+  const Total = () => <small>Total:{useStore(friendsTotal)}</small>
+  const User = () => <b>User:{useStore(name)}</b>
   const App = ({root}: {root: Scope}) => (
     <Provider value={root}>
       <section>
@@ -306,11 +306,11 @@ test('computed values support', async () => {
   expect(container.firstChild).toMatchInlineSnapshot(`
     <section>
       <b>
-        User: 
+        User:
         alice
       </b>
       <small>
-        Total: 
+        Total:
         2
       </small>
     </section>
@@ -337,8 +337,8 @@ test('useGate support', async () => {
     useGate(activeChatGate, {chatId})
     return (
       <div>
-        <header>Chat {chatId}</header>
-        <p>Messages total: {useStore(messagesAmount)}</p>
+        <header>Chat:{chatId}</header>
+        <p>Messages total:{useStore(messagesAmount)}</p>
       </div>
     )
   }
@@ -354,11 +354,11 @@ test('useGate support', async () => {
   expect(container.firstChild).toMatchInlineSnapshot(`
     <div>
       <header>
-        Chat 
+        Chat:
         chat01
       </header>
       <p>
-        Messages total: 
+        Messages total:
         2
       </p>
     </div>
@@ -373,11 +373,11 @@ test('useGate support', async () => {
   expect(container.firstChild).toMatchInlineSnapshot(`
     <div>
       <header>
-        Chat 
+        Chat:
         chat01
       </header>
       <p>
-        Messages total: 
+        Messages total:
         2
       </p>
     </div>
@@ -426,7 +426,7 @@ test('useEvent and effect calls', async () => {
     return (
       <div>
         <button id="btn" onClick={() => fxe()}>
-          clicked {x} times
+          clicked-{x}-times
         </button>
       </div>
     )
@@ -441,9 +441,9 @@ test('useEvent and effect calls', async () => {
       <button
         id="btn"
       >
-        clicked 
+        clicked-
         0
-         times
+        -times
       </button>
     </div>
   `)
@@ -455,12 +455,208 @@ test('useEvent and effect calls', async () => {
       <button
         id="btn"
       >
-        clicked 
+        clicked-
         1
-         times
+        -times
       </button>
     </div>
   `)
   expect(count.getState()).toBe(0)
   expect(scope.getState(count)).toBe(1)
 })
+
+test('object in useEvent', async () => {
+  const app = createDomain()
+  const inc = app.createEvent()
+  const dec = app.createEvent()
+  const fx = app.createEffect(async () => {
+    return 100
+  })
+  const count = app
+    .createStore(0)
+    .on(inc, x => x + 1)
+    .on(dec, x => x - 1)
+    .on(fx.doneData, (x, v) => x + v)
+  const scope = fork(app)
+  const App = () => {
+    const hndl = useEvent({fx, inc, dec})
+    const x = useStore(count)
+    return (
+      <div>
+        <span id="value">current value:{x}</span>
+        <button id="fx" onClick={() => hndl.fx()}>
+          fx
+        </button>
+        <button id="inc" onClick={() => hndl.inc()}>
+          inc
+        </button>
+        <button id="dec" onClick={() => hndl.dec()}>
+          dec
+        </button>
+      </div>
+    )
+  }
+  await render(
+    <Provider value={scope}>
+      <App />
+    </Provider>,
+  )
+  expect(container.firstChild).toMatchInlineSnapshot(`
+    <div>
+      <span
+        id="value"
+      >
+        current value:
+        0
+      </span>
+      <button
+        id="fx"
+      >
+        fx
+      </button>
+      <button
+        id="inc"
+      >
+        inc
+      </button>
+      <button
+        id="dec"
+      >
+        dec
+      </button>
+    </div>
+  `)
+  await act(async () => {
+    container.firstChild.querySelector('#fx').click()
+    container.firstChild.querySelector('#inc').click()
+    container.firstChild.querySelector('#inc').click()
+  })
+  expect(container.firstChild).toMatchInlineSnapshot(`
+    <div>
+      <span
+        id="value"
+      >
+        current value:
+        102
+      </span>
+      <button
+        id="fx"
+      >
+        fx
+      </button>
+      <button
+        id="inc"
+      >
+        inc
+      </button>
+      <button
+        id="dec"
+      >
+        dec
+      </button>
+    </div>
+  `)
+  await act(async () => {
+    container.firstChild.querySelector('#dec').click()
+  })
+  expect(count.getState()).toBe(0)
+  expect(scope.getState(count)).toBe(101)
+})
+
+test('array in useEvent', async () => {
+  const app = createDomain()
+  const inc = app.createEvent()
+  const dec = app.createEvent()
+  const fx = app.createEffect(async () => {
+    return 100
+  })
+  const count = app
+    .createStore(0)
+    .on(inc, x => x + 1)
+    .on(dec, x => x - 1)
+    .on(fx.doneData, (x, v) => x + v)
+  const scope = fork(app)
+  const App = () => {
+    const [a, b, c] = useEvent([fx, inc, dec])
+    const x = useStore(count)
+    return (
+      <div>
+        <span id="value">current value:{x}</span>
+        <button id="fx" onClick={() => a()}>
+          fx
+        </button>
+        <button id="inc" onClick={() => b()}>
+          inc
+        </button>
+        <button id="dec" onClick={() => c()}>
+          dec
+        </button>
+      </div>
+    )
+  }
+  await render(
+    <Provider value={scope}>
+      <App />
+    </Provider>,
+  )
+  expect(container.firstChild).toMatchInlineSnapshot(`
+    <div>
+      <span
+        id="value"
+      >
+        current value:
+        0
+      </span>
+      <button
+        id="fx"
+      >
+        fx
+      </button>
+      <button
+        id="inc"
+      >
+        inc
+      </button>
+      <button
+        id="dec"
+      >
+        dec
+      </button>
+    </div>
+  `)
+  await act(async () => {
+    container.firstChild.querySelector('#fx').click()
+    container.firstChild.querySelector('#inc').click()
+    container.firstChild.querySelector('#inc').click()
+  })
+  expect(container.firstChild).toMatchInlineSnapshot(`
+    <div>
+      <span
+        id="value"
+      >
+        current value:
+        102
+      </span>
+      <button
+        id="fx"
+      >
+        fx
+      </button>
+      <button
+        id="inc"
+      >
+        inc
+      </button>
+      <button
+        id="dec"
+      >
+        dec
+      </button>
+    </div>
+  `)
+  await act(async () => {
+    container.firstChild.querySelector('#dec').click()
+  })
+  expect(count.getState()).toBe(0)
+  expect(scope.getState(count)).toBe(101)
+})
diff --git a/src/types/__tests__/effector-react/effectorReact.test.js b/src/types/__tests__/effector-react/effectorReact.test.js
index e96c853e..bd2c53b3 100644
--- a/src/types/__tests__/effector-react/effectorReact.test.js
+++ b/src/types/__tests__/effector-react/effectorReact.test.js
@@ -1,8 +1,8 @@
 // @flow
 /* eslint-disable no-unused-vars */
 import * as React from 'react'
-import {createStore} from 'effector'
-import {createComponent, createGate, useGate} from 'effector-react'
+import {createStore, createEvent, createEffect} from 'effector'
+import {createComponent, createGate, useGate, useEvent} from 'effector-react'
 
 const typecheck = '{global}'
 
@@ -60,3 +60,66 @@ test('createGate', () => {
     "
   `)
 })
+
+test('useEvent of Event', () => {
+  const runEvent: (payload: number) => number = useEvent(createEvent<number>())
+  expect(typecheck).toMatchInlineSnapshot(`
+    "
+    --typescript--
+    no errors
+
+    --flow--
+    no errors
+    "
+  `)
+})
+
+test('useEvent of Effect', () => {
+  const runEffect: (payload: number) => Promise<string> = useEvent(
+    createEffect<number, string, Error>(),
+  )
+  expect(typecheck).toMatchInlineSnapshot(`
+    "
+    --typescript--
+    no errors
+
+    --flow--
+    no errors
+    "
+  `)
+})
+
+test('useEvent of object', () => {
+  const handlers: {
+    foo: (payload: number) => number,
+    bar: (payload: number) => Promise<string>,
+  } = useEvent({
+    foo: createEvent<number>(),
+    bar: createEffect<number, string, Error>(),
+  })
+  expect(typecheck).toMatchInlineSnapshot(`
+    "
+    --typescript--
+    no errors
+
+    --flow--
+    no errors
+    "
+  `)
+})
+
+test('useEvent of array', () => {
+  const handlers: [
+    (payload: number) => number,
+    (payload: number) => Promise<string>,
+  ] = useEvent([createEvent<number>(), createEffect<number, string, Error>()])
+  expect(typecheck).toMatchInlineSnapshot(`
+    "
+    --typescript--
+    no errors
+
+    --flow--
+    no errors
+    "
+  `)
+})