Skip to content

Commit

Permalink
feat(observable-hooks): add useLayoutObservableState
Browse files Browse the repository at this point in the history
  • Loading branch information
crimx committed Oct 15, 2021
1 parent 25f8eec commit 73439e9
Show file tree
Hide file tree
Showing 8 changed files with 371 additions and 29 deletions.
12 changes: 12 additions & 0 deletions docs/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ If no dependencies provided it is identical with [`useObservable`](#useobservabl
This is useful if you need values before next paint.
Use it scarcely as it runs synchronously before browser paint. Too many synchronous emissions from the observable could stretch the commit phase.
## useObservableCallback
```typescript
Expand Down Expand Up @@ -538,6 +540,16 @@ const text2$ = of('A', 'B', 'C')
const text2 = useObservableEagerState(text2$)
```
## useLayoutObservableState
Same as [`useObservableState`](#useobservablestate) except the subscription is established under `useLayoutEffect`.
Unlike [`useObservableEagerState`][#useobservableeagerstate] which gets state value synchronously before the first React rendering, `useLayoutObservableState` gets state value synchronously after React rendering,while `useObservableState` gets state value asynchronously after React rendering and browser paint.
Useful when values are needed before DOM paint.
Use it scarcely as it runs synchronously before browser paint. Too many synchronous emissions from the observable could stretch the commit phase.
## useObservableGetState
```typescript
Expand Down
3 changes: 2 additions & 1 deletion docs/guide/core-concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ Almost every RxJS-React binding library provides ways to connect observable valu

### Observable to State

In observable-hooks we have [`useObservableState`][useObservableState] and [`useObservableEagerState`][useObservableEagerState].
In observable-hooks we have [`useObservableState`][useObservableState], [`useObservableEagerState`][useObservableEagerState] and [`useLayoutObservableState`][useLayoutObservableState].

```
Expand Down Expand Up @@ -283,6 +283,7 @@ The [Epic](https://redux-observable.js.org/docs/basics/Epics.html)-like signatur
[useLayoutSubscription]: ../api/README.md#uselayoutsubscription
[useObservableState]: ../api/README.md#useobservablestate
[useObservableEagerState]: ../api/README.md#useobservableeagerstate
[useLayoutObservableState]: ../api/README.md#uselayoutobservablestate
[useObservableGetState]: ../api/README.md#useobservablegetstate
[useObservablePickState]: ../api/README.md#useobservablepickstate

Expand Down
10 changes: 10 additions & 0 deletions docs/zh-cn/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,16 @@ const text2$ = of('A', 'B', 'C')
const text2 = useObservableEagerState(text2$)
```
## useLayoutObservableState
与 [`useObservableState`](#useobservablestate) 基本一样,不同的是底下使用 [`useLayoutEffect`][useLayoutEffect] 监听改变。
与 [`useObservableEagerState`][#useobservableeagerstate] 不一样,[`useObservableEagerState`][#useobservableeagerstate] 会在第一次 React 渲染前同步获取值,而 `useLayoutObservableState` 是在 React 渲染之后同步获取值,`useObservableState` 则是在 React 渲染并绘制完成之后异步获取值。
如果需要在下次浏览器绘制前拿到值可以用它。
尽量少用,因为其是在浏览器绘制前同步调用。过多的同步值产生会延长组件的 commit 周期。
## useObservableGetState
```typescript
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import { useLayoutObservableState, identity } from '../src'
import { renderHook, act } from '@testing-library/react-hooks'
import { BehaviorSubject, of, Subject, throwError } from 'rxjs'
import { map, scan } from 'rxjs/operators'

describe('useLayoutObservableState', () => {
describe('with init function', () => {
it('should call the init function once', () => {
const timeSpy = jest.fn(() => of(Date.now()))
const { rerender } = renderHook(() => useLayoutObservableState(timeSpy))
expect(timeSpy).toBeCalledTimes(1)
rerender()
expect(timeSpy).toBeCalledTimes(1)
rerender()
expect(timeSpy).toBeCalledTimes(1)
})

it('should start receiving values after first rendering', () => {
const spy = jest.fn()
const { result } = renderHook(() => {
const state = useLayoutObservableState(() => of(1, 2, 3))
spy(state[0])
return state
})
expect(result.current[0]).toBe(3)
expect(spy).toHaveBeenCalledTimes(2)
expect(spy).toHaveBeenNthCalledWith(1, undefined)
expect(spy).toHaveBeenNthCalledWith(2, 3)
})

it('should update value when the returned function is called', () => {
const { result } = renderHook(() =>
useLayoutObservableState<string, number>(input$ =>
input$.pipe(map(input => 'test' + input))
)
)
const [state, updateState] = result.current
expect(state).toBeUndefined()
act(() => {
updateState(1)
})
expect(result.current[0]).toBe('test1')
act(() => {
updateState(2)
})
expect(result.current[0]).toBe('test2')
})

it('should get the init state if given', () => {
const { result } = renderHook(() => useLayoutObservableState(identity, 1))
expect(result.current[0]).toBe(1)
})

it('should get the init state of BehaviorSubject without initialState', () => {
const value$ = new BehaviorSubject('22')
const { result } = renderHook(() => useLayoutObservableState(value$))
expect(result.current).toBe('22')
})

it('should ignore the manual initialState for BehaviorSubject', () => {
const value$ = new BehaviorSubject('22')
const { result } = renderHook(() =>
useLayoutObservableState(value$, 'initialState')
)
expect(result.current).toBe('22')
})

it('should ignore the given init state when Observable also emits sync values', () => {
const { result } = renderHook(() =>
useLayoutObservableState(() => of(1, 2), 3)
)
expect(result.current[0]).toBe(2)
})

it('should throw error when observable emits error', () => {
const { result } = renderHook(() =>
useLayoutObservableState(() => throwError(new Error('oops')))
)
expect(result.error).toBeInstanceOf(Error)
expect(result.error.message).toBe('oops')
})

it('should support reducer pattern', () => {
interface StoreState {
value1: string
value2: number
}

type StoreAction =
| {
type: 'UPDATE_VALUE1'
payload: string
}
| {
type: 'INCREMENT_VALUE2'
}

const { result } = renderHook(() =>
useLayoutObservableState<StoreState, StoreAction>(
(action$, initialState) =>
action$.pipe(
scan((state, action) => {
switch (action.type) {
case 'UPDATE_VALUE1':
return {
...state,
value1: action.payload
}
case 'INCREMENT_VALUE2':
return {
...state,
value2: state.value2 + 1
}
default:
return state
}
}, initialState)
),
() => ({ value1: 'value1', value2: 2 })
)
)

let [state, dispatch] = result.current
expect(state).toEqual({ value1: 'value1', value2: 2 })

act(() => {
dispatch({ type: 'UPDATE_VALUE1', payload: 'value2' })
})

state = result.current[0]
expect(state).toEqual({ value1: 'value2', value2: 2 })

act(() => {
dispatch({ type: 'INCREMENT_VALUE2' })
})

state = result.current[0]
expect(state).toEqual({ value1: 'value2', value2: 3 })
})
})

describe('with init Observable', () => {
it('should start receiving values after first rendering', () => {
const spy = jest.fn()
const outer$ = of(1, 2, 3)
const { result } = renderHook(() => {
const state = useLayoutObservableState(outer$)
spy(state)
return state
})
expect(result.current).toBe(3)
expect(spy).toHaveBeenCalledTimes(2)
expect(spy).toHaveBeenNthCalledWith(1, undefined)
expect(spy).toHaveBeenNthCalledWith(2, 3)
})

it('should update value when the Observable emits value', () => {
const outer$$ = new Subject<number>()
const { result } = renderHook(() => useLayoutObservableState(outer$$))
expect(result.current).toBeUndefined()
act(() => {
outer$$.next(1)
})
expect(result.current).toBe(1)
act(() => {
outer$$.next(2)
})
expect(result.current).toBe(2)
})

it('should get the init state if given', () => {
const outer$$ = new Subject()
const { result } = renderHook(() => useLayoutObservableState(outer$$, 1))
expect(result.current).toBe(1)
})

it('should ignore the given init state when Observable also emits sync values', () => {
const outer$ = of(1, 2)
const { result } = renderHook(() => useLayoutObservableState(outer$, 3))
expect(result.current).toBe(2)
})

it('should throw error when observable emits error', () => {
const outer$ = throwError(new Error('oops'))
const { result } = renderHook(() => useLayoutObservableState(outer$, 3))
expect(result.error).toBeInstanceOf(Error)
expect(result.error.message).toBe('oops')
})
})
})
1 change: 1 addition & 0 deletions packages/observable-hooks/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export { useObservableCallback } from './use-observable-callback'
export { useSubscription } from './use-subscription'
export { useLayoutSubscription } from './use-layout-subscription'
export { useObservableState } from './use-observable-state'
export { useLayoutObservableState } from './use-layout-observable-state'
export { useObservableEagerState } from './use-observable-eager-state'
export { useObservableGetState } from './use-observable-get-state'
export { useObservablePickState } from './use-observable-pick-state'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { Observable, isObservable, Subject } from 'rxjs'
import { useState, useRef, useDebugValue } from 'react'
import type { useSubscription as useSubscriptionType } from '../use-subscription'
import { useRefFn, getEmptySubject } from '../helpers'

export function useObservableStateInternal<TState, TInput = TState>(
useSubscription: typeof useSubscriptionType,
state$OrInit:
| Observable<TState>
| ((
input$: Observable<TInput>,
initialState?: TState
) => Observable<TState>),
initialState?: TState | (() => TState)
): TState | undefined | [TState | undefined, (input: TInput) => void] {
const [state, setState] = useState<TState | undefined>(initialState)

let callback: undefined | ((input: TInput) => void)
let state$: Observable<TState>

if (isObservable(state$OrInit)) {
state$ = state$OrInit
} else {
const init = state$OrInit
// Even though hooks are under conditional block
// it is for a completely different use case
// which unlikely coexists with the other one.
// A warning is also added to the docs.
const input$Ref = useRefFn<Subject<TInput>>(getEmptySubject)

state$ = useRefFn(() => init(input$Ref.current, state)).current
callback = useRef((state: TInput) => input$Ref.current.next(state)).current
}

useSubscription(state$, setState)

// Display state in React DevTools.
useDebugValue(state)

return callback ? [state, callback] : state
}

0 comments on commit 73439e9

Please sign in to comment.