withObservables icon indicating copy to clipboard operation
withObservables copied to clipboard

useObservables for react hooks

Open ericvicenti opened this issue 7 years ago • 82 comments

Hey all, I've found withObservables to be super handy, but now with react hooks I've been using a simple "useObservables" instead:

import { useState, useEffect } from 'react';

export default function useObservable(observable) {
  const [value, setValue] = useState(observable && observable.value);

  useEffect(
    () => {
      if (!observable || !observable.subscribe) {
        return;
      }
      const subscription = observable.subscribe(setValue);
      return () => subscription.unsubscribe();
    },
    [observable]
  );

  return value;
}

Usage:

function MyView({ observable }) {
  const currentValue = useObservable(observable);
  return <Text>{currentValue}</Text>
}

Note: it is best to use ObservableBehavior so that the observable.value can be accessed synchronously for the first render

ericvicenti avatar Dec 12 '18 22:12 ericvicenti

I'm just getting started with WatermelonDB, Observables and React hooks. I had some problems with the above code causing endless re-renders .. I suspect due to the ObservableBehavior as mentioned, but I don't fully understand what that means 😞

However, I did have success with this useObservable library, so I thought I'd just leave it here in case it helps someone else 😄

kilbot avatar Feb 10 '19 18:02 kilbot

The useObservable hook I am currently using is:

import { Subject, Subscription } from 'rxjs';
import { useEffect, useMemo, useState } from 'react';

export default function useObservable(observable, initial, inputs = []) {
  const [state, setState] = useState(initial);
  const subject = useMemo(() => new Subject(), inputs);

  useEffect(() => {
    const subscription = new Subscription();
    subscription.add(subject);
    subscription.add(subject.pipe(() => observable).subscribe(value => setState(value)));
    return () => subscription.unsubscribe();
  }, [subject]);

  return state;
}

Usage:

function MyView({ observable }) {
  const currentValue = useObservable(observable, 'initial', [observable, triggers]);
  return <Text>{currentValue}</Text>
}

I was going to post a PR to this repository ... but import { useObservable } from '@nozbe/with-observables' feels a little weird. Perhaps it would be better to have some sort of monorepo for hooks? Especially if @brunolemos has ideas for more hooks :smile:

kilbot avatar Mar 02 '19 04:03 kilbot

I was going to post a PR to this repository ... but import { useObservable } from '@nozbe/with-observables' feels a little weird.

I know! I think it's best to post it anyway, and then figure out what's the best name… I imagine withObservables will be obsolete/deprecated in a year when everyone switches to Hooks anyway...

radex avatar Mar 04 '19 15:03 radex

@kilbot as for your hook, I think it would be cleaner and more performant to avoid extra Subject, and just set state based on observable subscription. Another issue is the need for initial prop. If you have it - it's great, but it would be best to use Suspense to prevent further render and just wait until we can get our hands on the value subscribed to. WDYT?

radex avatar Mar 14 '19 16:03 radex

I was having a problem with endless loops, ie: the first subscription was setting the state which triggered a rerender which started the process again. The extra Subject was an effort to get around that. However, I only started learning about observables when I wanted to use WatermelonDB .. so I'm a bit out of my depth 😓

I started rewriting the example app for WatermelonDB with typescript and hooks - mostly as a learning exercise for myself - I'll take another look at it this weekend to see if I reproduce the rerender issue I was having in my app.

kilbot avatar Mar 14 '19 17:03 kilbot

Hi @radex, I've created some example code to illustrate the infinite loop problem I am having. Please compare these two Netlify builds: useObservable with extra Subject and useObservable without. You'll see the subscribe/unsubscribe loop in the console.

Click here to ~~compare the code~~(updated below) and here is the initial subscription which causes the problems.

It's a bit tricky to share code because I can't load everything into CodeSandbox .. but let me know if you spot anything!

kilbot avatar Mar 16 '19 18:03 kilbot

I've just had a look at this again with fresh eyes and realise it is the removal of the observable as a dependency not the addition of the Subject which stopped the infinite loop, eg:

export default function useObservable(observable: Observable, initial?: any, deps: any[] = []) {
  const [state, setState] = useState(initial);

  useEffect(() => {
    const subscription = observable.subscribe(setState);
    return () => subscription.unsubscribe();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, deps);

  return state;
}

This seems to work okay.

It goes against the advice from the React team to remove the observable dependency, so there still may be some issues that I'm unaware of ... I'll have to do some more reading 😅

kilbot avatar Mar 17 '19 04:03 kilbot

This seems very similar to how react-apollo-hooks works!

ericlewis avatar Apr 12 '19 02:04 ericlewis

FYI! I'm working on a fully supported, tested & high-performance solution for hooks :)

This is not easy, because:

  • asynchronicity means we might not have the data on first call to useObservable, but caching means we might have
  • hooks can't cause the component to bail out early and return null (the way HOCs can)
  • Suspense is not fully supported yet
  • there's no support for disposing resources for uncommitted renders, which means we have to do some hacking to get the right performance & DX (See: https://github.com/facebook/react/issues/15317#issuecomment-491269433 _

here's some snippets of the work I'm doing.

Basic useEffect-based implementation:

export const neverEmitted = Symbol('observable-never-emitted')

export function useObservableSymbol<T>(observable: Observable<T>): T | typeof neverEmitted {
  const [value, setValue] = useState(neverEmitted)

  useEffect(() => {
    const subscription = observable.subscribe(newValue => {
      setValue(newValue)
    })
    return () => subscription.unsubscribe()
  }, [observable])

  return value
}

WIP highly-optimized implementation, but one that only works on BehaviorSubjects, and cached Observables (essentially, observables that can emit a value synchronously):

export function useObservableSync<T>(observable: Observable<T>): T {
  const forceUpdate = useForceUpdate()

  const value = useRef(neverEmitted)
  const previousObservable = useRef(observable)
  const subscription = useRef(undefined)

  if (observable !== previousObservable.current) {
    throw new Error('Passing different Observable to useObservable hook is not supported (yet)')
  }

  if (subscription.current === undefined) {
    const newSubscription = observable.subscribe(newValue => {
      value.current = newValue

      if (subscription.current !== undefined) {
        forceUpdate()
      }
    })
    subscription.current = newSubscription
  }

  // TODO: GC subscription in case component never gets mounted

  if (value.current === neverEmitted) {
    throw new Error('Observable did not emit an initial value synchronously')
  }

  useEffect(() => {
    return () => subscription.current.unsubscribe()
  }, [])

  return value.current
}

The plan is to take advantage of Suspense (or simulated suspense using error boundaries), so that you can just call const value = useObservable(query), without having to worry about initial value, and it does the right thing.

But you'd still have to wrap it somewhere with a <Supense> or a custom <ObservableSuspense> or something like that. I don't think there's a way around it.

I'd also like to publish an official prefetched component, so that you can do:

<Prefetch observables=[query, otherQuery, etc]>
  <SomeComponentTreeThatUsesUseObservableHook />
</Prefetch>

this would be much faster than relying on suspense, since you can start db operations early, and by the time you get to rendering components, you have a synchronous data source, instead of constantly rolling back partial renders.

Would someone be willing to help out with writing tests for this?

radex avatar May 11 '19 12:05 radex

This is great @radex! I would be keen to help out in any way I can.

I'll leave a link to mobx-react-lite here, just in case it is useful. There is a discussion on getting MobX to work with react hooks in concurrent mode, I guess there may be some parallels for WatermelonDB?

kilbot avatar May 12 '19 04:05 kilbot

@kilbot Thanks! I've seen that thread and mobx-react-lite, and I was planning to spend some time next Friday digging deeper into it to understand how they did it ;) Although MobX doesn't really have asynchronicity issues AFAICT.

radex avatar May 12 '19 12:05 radex

Just want to point out we have mobx-react-lite@next which includes experimental support for Concurrent mode. Feel free to try that.

danielkcz avatar Aug 15 '19 18:08 danielkcz

@FredyC Thanks! I'll check it out!

radex avatar Aug 19 '19 14:08 radex

observable-hooks supports Observables and Suspense which I think is super handy to use in conjunction with withObservables.

crimx avatar Feb 24 '20 09:02 crimx

@crimx Amazing work! Looks like roughly what I was going for, but never had time to implement (yet) :) … although I do see some potential perf issues. I will check it out in the coming months...

radex avatar Feb 24 '20 12:02 radex

That would be great! Much appreciated🍻.

crimx avatar Feb 24 '20 12:02 crimx

Hi there. Any news on this?

gliesche avatar Jul 15 '20 09:07 gliesche

@gliesche Not much! I recently got back to looking at it, and implemented an useModel() hook (not ready to be open sourced yet), but as you can see from the whole discussion, making a generic useObservables without a HOC, without compromises, and without forcing synchronous Watermelon is… well, very complicated :)

radex avatar Jul 16 '20 13:07 radex

@gliesche Not much! I recently got back to looking at it, and implemented an useModel() hook (not ready to be open sourced yet), but as you can see from the whole discussion, making a generic useObservables without a HOC, without compromises, and without forcing synchronous Watermelon is… well, very complicated :)

Thanks for the update. I tried observable-hooks, but that didn't really work. I'd really be interested in any working solution that uses hooks and observes WatermelonDB collections / models.

gliesche avatar Jul 16 '20 13:07 gliesche

@gliesche For context: what problems are you looking to solve with a hook-based WatermelonDB observation? Do you just care about nice, consistent API? Or are there composition/performance/other problems you're running into because of this?

radex avatar Jul 16 '20 13:07 radex

@gliesche For context: what problems are you looking to solve with a hook-based WatermelonDB observation? Do you just care about nice, consistent API? Or are there composition/performance/other problems you're running into because of this?

Consistent API is one main point, yes. Though I am not sure, if hook based observation is needed for custom hooks...

gliesche avatar Jul 16 '20 13:07 gliesche

FYI since the last conversation observable-hooks had been refactored a little bit and had been made concurrent mode safe.

@gliesche what problems did you encounter? AFAIK watermelon observables are just plain RxJS observables so they should just work out of the box right?

crimx avatar Jul 16 '20 23:07 crimx

(@crimx FWIW we're moving away from RxJS internally for performance reasons, but yes, the outside API is RxJS-compatible)

radex avatar Jul 17 '20 16:07 radex

FYI since the last conversation observable-hooks had been refactored a little bit and had been made concurrent mode safe.

@gliesche what problems did you encounter? AFAIK watermelon observables are just plain RxJS observables so they should just work out of the box right?

@crimx I guess I don't get the API right, I tried something like:

  const database = useDatabase();
  const input$ = database.collections.get('posts').query().observe();
  const [posts, onPosts] = useObservableState(input$, null);

Which results in a TypeError: rxjs_1.isObservable is not a function

gliesche avatar Jul 20 '20 16:07 gliesche

isObservable is included in RxJS. What RxJS version are you using?

This should work

const database = useDatabase();
// reduce recomputation
const input$ = useObservable(() => database.collections.get('posts').query().observe());
// if the first parameter is an Observable `useObservableState` returns emitted values directly.
const posts = useObservableState(input$, null);

@radex is the observable from watermelon hot and with inital value? I am trying to implement a hook that subscribe synchronously. In concurrent mode it is only safe if the observable is hot.

crimx avatar Jul 20 '20 23:07 crimx

isObservable is included in RxJS. What RxJS version are you using?

The version of the metro bundler is being used: [email protected]

gliesche avatar Jul 21 '20 07:07 gliesche

@gliesche observable-hooks is meant to work with RxJS 6. Is there a reason that you still use RxJS 5? Is it a legacy project?

crimx avatar Jul 21 '20 08:07 crimx

@gliesche observable-hooks is meant to work with RxJS 6. Is there a reason that you still use RxJS 5? Is it a legacy project?

It is not a legacy project. It's a react native project v0.62.2. Do I need to install the peer deps manually? I did not explicitly add a version of rjxs.

gliesche avatar Jul 21 '20 08:07 gliesche

Do I need to install the peer deps manually?

Yes. Read this article if you are interested.

What we need is a way of expressing these "dependencies" between plugins and their host package. Some way of saying, "I only work when plugged in to version 1.2.x of my host package, so if you install me, be sure that it's alongside a compatible host." We call this relationship a peer dependency.

crimx avatar Jul 21 '20 08:07 crimx

@radex is the observable from watermelon hot and with inital value? I am trying to implement a hook that subscribe synchronously. In concurrent mode it is only safe if the observable is hot.

@crimx It's not guaranteed to be hot — that's a problem. TL;DR: I'm working towards it.

I'm developing Nozbe Teams (the project that birthed WatermelonDB) with the intention on making all DB work synchronous so that React components can render in one shot… This works 100% of the time on web, but not yet on native (where synchronous native modules can't be guaranteed to work all the time - YET).

The longer-term plan is to go back to more multi-threading, but such that you can synchronously get values you need for first render, but can prefetch and cache queries beforehand on separate thread, and have them hot when you render.

radex avatar Jul 21 '20 11:07 radex