Nozbe / withObservables

HOC (Higher-Order Component) for connecting RxJS Observables to React Components
https://github.com/Nozbe/WatermelonDB
MIT License
66 stars 26 forks source link

useObservables for react hooks #16

Open ericvicenti opened 5 years ago

ericvicenti commented 5 years ago

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

rohankm commented 11 months ago

We need hooks for watermelon db please

ftaibi commented 2 months ago

We need hooks asap pls

okalil commented 2 months ago

Hey, just to share, my current "workaround" is using Watermelon queries with React Query, like that:

const { data: posts } = useQuery({
  queryKey: ['comments'],
  queryFn: () => database.get('comments').query().fetch(),
})

And for a write:

const { isPending } = useMutation({
  async mutationFn: () => {
    await database.write(async () => {
      const comment = await database.get('comments').find(commentId)
      await comment.update(() => {
        comment.isSpam = true
      })
    })
  },
  onSuccess: () => queryClient.invalidateQueries(['comments'])
})

Even though react query is very popular for network requests, it works for any async data source, so that's fine:

The con is that you need to invalidate the query manually after the mutation; The pro is that is an agnostic solution, even if you exchange watermelon db for something else, will still work with any async data source; Besides, I personally think it's easier and more flexible than wrapping components with a hoc or dealing with observables transformations in more complex cases.

bensenescu commented 2 months ago

Hey, just to share, my current "workaround" is using Watermelon queries with React Query, like that:

@okalil This is awesome! I was thinking this might work, but was scared away by the warnings in the docs.

Have you gotten reactivity to work? I think your linked implementation will do a fresh query everytime the component loads the first time. But, have you gotten it to update while the user is on a screen if you get new or updated records during sync? I'm thinking something like invalidating the queries in your pull might work?

832bb9 commented 2 months ago

After more then year of using watermelon with react native i would suggest everyone to use https://observable-hooks.js.org/api/#useobservableeagerstate. I did not discover any downsides. The main benefit is that it provides data synchronously and thus no need to handle async state like in react query approach.

okalil commented 2 months ago

@bensenescu react query is basically a memory cache, and there's a staleTime option that can be set to reduce refetch frequency, even avoiding it at all. Regarding sync, I only used custom implementations, but I think invalidating everything after pull should work like you said

Stophface commented 2 months ago

@832bb9 Could you give an example?

832bb9 commented 2 months ago

@Stophface Yes, sure.

For example you have some todo app. Then you can render data from WatermelonDB like:

import { useDatabase } from '@nozbe/watermelondb/hooks'
import { useObservable, useObservableEagerState } from 'observable-hooks'
import { Text } from 'react-native'
import { switchMap as switchMap$ } from 'rxjs/operators'

export const Task = ({ taskId }: { taskId: string }) => {
  const database = useDatabase()

  // - resolves synchronously (no loading state)
  // - reactive (whenever task changed in db either after sync or local update this component will re-render)
  const task = useObservableEagerState(
    useObservable(
      (inputs$) => switchMap$(([taskId]) => database.get('tasks').findAndObserve(taskId))(inputs$),
      [taskId],
    ),
  )

  return <Text>{task.subject}</Text>
}

Here is an alternative using react-query:

import { useDatabase } from '@nozbe/watermelondb/hooks'
import { useQuery } from '@tanstack/react-query'
import { Text } from 'react-native'

export const Task = ({ taskId }: { taskId: string }) => {
  const database = useDatabase()

  // - resolves asynchronously (needs to handle loading state)
  // - non reactive (you need to invalidate query key manually after sync or local update)
  const taskQuery = useQuery(['task', { taskId }], () => database.get('tasks').find(taskId))

  if (taskQuery.isLoading) {
    return <Text>Loading...</Text>
  }

  if (taskQuery.isError) {
    return <Text>Something went wrong</Text>
  }

  const task = taskQuery.data

  return <Text>{task.subject}</Text>
}

I would much prefer to use first approach for WatermelonDB, while continue using react-query for data which need to be fetched from server separately, like statistics, if there is no need for it to be updated from client or work offline.

Stophface commented 2 months ago

@832bb9 Thanks for the quick response. I am trying to modify your first example with useObservable to monitor a whole table and get the whole table when any of the rows is updated. I am trying it like this

import { useObservable, useObservableEagerState } from 'observable-hooks'
import { switchMap as switchMap$ } from 'rxjs/operators'

const data = useObservableEagerState(
    useObservable(
      (inputs$) => switchMap$(() => userDatabase.get('my_table').observe())(inputs$)
    )
  )

which gives me

TypeError: Unable to lift unknown Observable type

I also tried it like this

import { useObservable, useObservableEagerState } from 'observable-hooks'
import { switchMap as switchMap$ } from 'rxjs/operators'

  const data = useObservableEagerState(
    useObservable(
      () => switchMap$(() => userDatabase.get('my_table').observe())
    )
  )

which gives me

TypeError: state$.subscribe is not a function (it is undefined)

Is there a way to observe a complete table?

Stophface commented 2 months ago

@832bb9 Or is that only working if I observe specific rows for changes? It would be super awesome if you could come back to me, because I struggle getting watermelonDB to work with the observables...

jessep commented 1 month ago

@Stophface The problem is, as you said, that was looking for specific rows. Here's an example from what I'm making that works for a query of a list.

  const foci = useObservableEagerState(
    useObservable(
      () => db.get<FocusModel>('foci').query().observe(),
      [],
    ),
  )
Stophface commented 1 month ago

@jessep Thanks for your answer. If I implement it like this

import { useDatabase } from '@nozbe/watermelondb/hooks'
import { useObservable, useObservableEagerState } from 'observable-hooks'
import { Text } from 'react-native'

export const Foo = () => {
  const database = useDatabase()

  const tableData = useObservableEagerState(
    useObservable(
      () => db.get('some_table').query().observe(),
      [],
    ),
  )

  return <Text>{tableData.name}Text>
}

I get

Error: Observable did not synchronously emit a value.

Stophface commented 1 month ago

@crimx

I have this hook

import { useObservable, useObservableState } from 'observable-hooks'

const tableUpdated = (table) => {
    const database = useDatabase();

    const data = useObservableState(
        useObservable(
            () => database.get(table).query().observe(),
            [],
        ),
    )
    return data;
};

export default tableUpdated;

Which I use the following

const Foo = () => {
    const posts = tableUpdated('posts')

    return <p>{posts.text}</p>
}

Now, what I actually need is something comparable to useEffect with a dependency on the table 'posts'. Whenever the table gets updated, I need the useEffect to run and perform some actions and finally set a local state.

According to the documentation you are supposed to use useSubscription. But I have no clue how to get the following (here: wrong with useEffect) running with useSubscription

const Foo = () => {
    const [foo, setFoo] = useState(null)
    const posts = tableUpdated('posts')

    useEffect(() => {
        if(posts) {
            const result = ... // do sth
            setFoo(result)
    }, [posts])

    return <p>{foo}</p>
}

You seem to know your way around observables?

crimx commented 1 month ago

@Stophface like this?

import { useObservable, useObservableState, useSubscription } from 'observable-hooks'
import { switchMap } from 'rxjs';

const useTable$ = (table) => {
    const database = useDatabase();
    return useMemo(
      () => database.get(table).query().observe(),
      [database, table]
    );
};

const posts$ = useTable$("posts");

const posts = useObservableState(posts$);

useSubscription(posts$, (posts) => {
  console.log(posts);
});
Stophface commented 1 month ago

@crimx Thanks for coming back to me. Looks like what I am looking for, but when I test your code in my component like this

import { useObservableState, useSubscription } from 'observable-hooks'
import { switchMap } from 'rxjs';

const Foo = () => {
  const useTable$ = (table) => {
    const database = useDatabase();
    return useMemo(
      () => database.get(table).query().observe(),
      [database, table]
    );
  };

  const posts$ = useTable$("posts");
  const posts = useObservableState(posts$);
  useSubscription(posts$, (posts) => {
    console.log(posts);
  });

  return <p>...</p>
}

It does not console.log anything when the database gets updated.

I am halfway there, because this

const database = useDatabase();
const data = useObservableState(
    useObservable(
        () => database.get('posts').query().observe(),
        [],
    ),
)

console.log(data)

console.logs everytime the database is updated, if I put it directly into a functional component.

const Foo = () => {
    const database = useDatabase();
    const data = useObservableState(
        useObservable(
            () => database.get('posts').query().observe(),
            [],
        ),
    )

    console.log(data)

   return <p>{data.name}</p>
}

But its kinda "useless" if I am not able to perform some (async) calculations, setting local state with the result etc. on it, which must be done in useSubscription since I cannot do it where the result of useObservableState is currently written into data.

crimx commented 1 month ago

I could not reproduce your issue. Here is the code I used on the watermelondb example.

const useTable$ = (database, table) => {
  return useMemo(
    () => database.collections.get(table).query().observe(),
    [database, table]
  )
}

function MyPost({ database }) {
  const posts$ = useTable$(database, 'posts')
  useSubscription(posts$, posts => {
    console.log('[posts] Post changed!', posts)
  })
  return null
}

https://github.com/Nozbe/withObservables/assets/6882794/28d0573a-abce-40bf-ad94-60ac295cda5d

Stophface commented 1 month ago

@crimx Perfect, this works like a charm! Thanks a lot for sticking with me!

Stophface commented 1 month ago

@crimx Do you know how to observe not a complete table, but only a record based on a condition? If I had this query

await database
        .get('posts')
        .query(
          Q.where('postId', postId),
          Q.and(Q.where('authorId', Q.eq(authorId))),
        )
        .fetch();

how would I write it with observables?

crimx commented 1 month ago

Sorry I am not familiar with advanced watermelondb API. Base on the code you posted, are you looking for something like from which converts a promise to into an observable?

832bb9 commented 1 month ago

@Stophface you need to use observe method instead of fetch then, like:

  const post = useObservableEagerState(
    useObservable(
      (inputs$) => 
        inputs$.pipe(
          switchMap$(([authorId, postId]) =>
            database
              .get('posts')
              .query(Q.where('postId', postId), Q.and(Q.where('authorId', Q.eq(authorId))))
              .observe(),
          ),
          map$((posts) => posts[0])
        ),
      [authorId, postId],
    ),
  )

Also i am not fully sure what you are trying to achieve with that query. postId seems to be already enough to find exact record in db, you don't need to check author in query then. Also Q.and expecting more then one argument, it is useless otherwise. WatermelonDB expose findAndObserve method specifically for such case.

  const post = useObservableEagerState(
    useObservable(
      (inputs$) => inputs$.pipe(switchMap$(([postId]) => database.get('posts').findAndObserve(postId)),
      [postId],
    ),
  )

But it will work only with id field, which is required. I see that you are using postId in query and I am not sure how it differ from id in your schema.

832bb9 commented 1 month ago

@crimx from is great when you don't have observables, fortunately WatermelonDB is built on top of RxJS and expose all necessary APIs to work with them. This allows component to react to db changes, while fetch method (even wrapped with observable) don't call subscribe, it just returns current state from db.

Stophface commented 1 month ago

@832bb9

Also i am not fully sure what you are trying to achieve with that query. postId seems to be already enough to find exact record in db, you don't need to check author in query then. Also Q.and expecting more then one argument, it is useless otherwise.

I just came up with an example that contains a query :) When I try your suggestion

import { useMemo } from 'react';
import { useObservableState, useSubscription, useObservable } from 'observable-hooks'
import { map$, switchMap$ } from "rxjs";

const useTable$ = (database, postId) => {
    return useObservableState(
      useObservable(
        (inputs$) =>
          inputs$.pipe(
            switchMap$(([postId]) =>
              database
                .get('posts')
                .query(Q.where('id', postId))
                .observe(),
            ),
            map$(records => records[0])
          ),
        [database, postId],
      ),
    )
}

const SomeFunctionalComponent () => {
  const data$ = useTable$(database, 1)
  useSubscription(data$, records => {
    console.log(records)
  })
  return null
}

I get

TypeError: 0, _$$_REQUIRE(_dependencyMap[13], "rxjs").switchMap$ is not a function (it is undefined)

832bb9 commented 1 month ago

This is because rxjs package don't have such members, I am renaming them while import to satisfy so called Finnish Notation (https://benlesh.medium.com/observables-and-finnish-notation-df8356ed1c9b). You are free not to use it, but I just don't want to mix rxjs operators with ramda operators I am using in same files. You shouldn't add $ sign to anything except variables which holds observables or functions which returns them, thats why useTable$ and data$ is wrong. You don't need to call useSubscription, useObservableState already passes data from observable to react component world, so you can use useEffect if you want to do anything with it.

import { useDatabase } from '@nozbe/watermelondb/hooks'
import { useObservable, useObservableState } from 'observable-hooks'
import { useEffect } from 'react'
import { switchMap as switchMap$ } from 'rxjs'

const useObservePost = (postId: string) => {
  const database = useDatabase()

  const post = useObservableEagerState(
    useObservable(
      (inputs$) => inputs$.pipe(switchMap$(([database, postId]) => database.get('posts').findAndObserve(postId))),
      [database, postId],
    ),
  )

  return post
}

const SomeFunctionalComponent = () => {
  const post = useObservePost('1')

  useEffect(() => {
    console.log(post)
  }, [post])

  return null
}
Stophface commented 1 month ago

@832bb9 Thanks again. This works :)!