acdlite / recompose

A React utility belt for function components and higher-order components.
MIT License
14.75k stars 1.26k forks source link

Rewriting mapPropsStream with Hooks or new Lifecycle Methods #783

Open jameslaneconkling opened 5 years ago

jameslaneconkling commented 5 years ago

As a result of React's deprecation of the componentWillMount and componentWillReceiveProps lifecycle hooks, recompose's mapPropsStream now warns

Warning: componentWillMount has been renamed, and is not recommended for use. See https://fb.me/react-async-component-lifecycle-hooks for details.

I've been looking for an equivalent implementation using either Hooks, the new Suspense API, or new lifecycle methods, without much luck. The following works using the unsafe lifecycle methods

const mapPropsStream = (project) => (wrappedComponent) =>
  class MapPropsStream extends Component {

    state = { mappedProps: undefined }
    props$ = new Subject()

    componentDidMount() {
      this.subscription = this.props$.pipe(startWith(this.props), project).subscribe((mappedProps) => {
        this.setState({ mappedProps })
      })
    }

    UNSAFE_componentWillReceiveProps(nextProps) {
      this.props$.next(nextProps)
    }

    shouldComponentUpdate(props, state) {
      return this.state.mappedProps !== state.mappedProps
    }

    componentWillUnmount() {
      this.subscription.unsubscribe()
    }

    render() {
      return this.state.mappedProps === undefined ?
        null :
        createElement(wrappedComponent, this.state.mappedProps)
    }
  }

However, I haven't been able to accomplish the same thing without UNSAFE_componentWillReceiveProps. Given that renders are triggered by changes to the props$ props stream, and not the props themselves, I suspect that the Suspense API could be helpful. Curious if anyone else is tackling the same issue.

viztor commented 5 years ago

the author has discontinued support for recompose and recommended react hook as a replacement.

jameslaneconkling commented 5 years ago

Understood, and I think that's a fine choice. I'm just not sure how (or even if, given the current state of the hooks API) it would be possible to recreate the functionality of mapPropsStream using hooks.

At issue is delaying the component's render from when it receives props to when the observable emits (which may be immediate or not). The current recompose implementation, and the above simplified reimplementation, essentially achieve that by using shouldComponentUpdate. Deferring rendering is not something that's supported by hooks atm, though I suspect the suspense API is intended for this type of use case.

gemma-ferreras commented 5 years ago

@jameslaneconkling Did you manage to recreate the functionality of mapPropsStream using hooks? I am looking for this

jameslaneconkling commented 5 years ago

@gemma-ferreras the solution I've ended up w/ looks like:

const useStream = <T, R>(
  project: (stream$: Observable<T>) => Observable<R>,
  data: T,
): R | undefined => {
  const prev = useRef<T>()
  const stream$ = useRef(new Subject<T>())
  const emit = useRef<R>()
  const synchronous = useRef(true)
  const [_, rerender] = useState(false)

  useLayoutEffect(() => {
    const subscription = stream$.current.pipe(project).subscribe({
      next: (next) => {
        emit.current = next
        if (!synchronous.current) {
          rerender((prev) => !prev)
        }
      }
    })

    stream$.current.next(data)
    return () => subscription.unsubscribe()
  }, [])

  synchronous.current = true
  if (prev.current !== data) {
    emit.current = undefined
    stream$.current.next(data)
  }
  prev.current = data
  synchronous.current = false

  return emit.current
}

It's a little bit wordier than I'd hoped, but essentially subscribes to a stream for the lifecycle of the component, while ensuring that synchronous emits don't render twice. To use:

export const Widget: SFC<{}> = () => {
  const [channel, setChannel] = useState('friend-list')
  const selectChannel = useCallback(({ target: { value } }) => setChannel(value), [])
  const next = useStream((stream$) => stream$.pipe(
    switchMap(() => interval(500).pipe(
      startWith(-1),
      scan<number, number[]>((data) => [...data, Math.floor(Math.random() * 10)], []),
      take(4),
    )),
  ), channel)

  return el('div', null,
    el('div', null,
      el('select', { value: channel, onChange: selectChannel },
       el('option', { value: 'friend-list' }, 'Friends'),
       el('option', { value: 'enemy-list' }, 'Enemies'),
       el('option', { value: 'grocery-list' }, 'Groceries'))),
    el('h1', null, channel),
    el('ul', null, ...(next || []).map((item, idx) => (
      el('li', { key: idx }, item))))
  )
}

(accidentally fat finger closed this--just reopened)