Nozbe / withObservables

HOC (Higher-Order Component) for connecting RxJS Observables to React Components
https://github.com/Nozbe/WatermelonDB
MIT License
68 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 1 year ago

We need hooks for watermelon db please

ftaibi commented 7 months ago

We need hooks asap pls

okalil commented 7 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 7 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 7 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 7 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 7 months ago

@832bb9 Could you give an example?

832bb9 commented 7 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 7 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 7 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 6 months 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 6 months 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 6 months 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 6 months 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 6 months 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 6 months 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 6 months ago

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

Stophface commented 6 months 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 6 months 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 6 months 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 6 months 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 6 months 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 6 months 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 5 months ago

@832bb9 Thanks again. This works :)!

heliocosta-dev commented 4 months 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
}

Thanks! This works like a charm. I made some changes to it so it allows one to fetch data from any table:

import { switchMap as switchMap$ } from 'rxjs';
import { useObservable, useObservableEagerState } from 'observable-hooks';
import { useDatabase } from '@nozbe/watermelondb/react';
import type { Model, Q } from '@nozbe/watermelondb';
import type { TableName } from '@models/schema';

const useDatabaseData = <T extends Model>(
  tableName: TableName,
  query: Q.Clause[] = [],
) => {
  const database = useDatabase();

  const data = useObservableEagerState(
    useObservable(
      inputs$ =>
        inputs$.pipe(
          switchMap$(([db]) => db.get(tableName).query(query).observe()),
        ),
      [database],
    ),
  );

  return data as T[];
};

export default useDatabaseData;

Are there any performance issues or any other flaws/edge cases with the above?

ertucaglar commented 3 days ago

Hello guys, I am having a issue and I want to make sure I understand it correctly.

I have a page structure as below and I get the data with useDatabaseData, there is no problem here.

TestPage.tsx

const data = useDatabaseData<CollectionEntity>(CollectionEntity.table, [
    Q.take(100),
  ]);

I am using flatList in this page

 const renderItem = ({item}: ListItemData<CollectionEntity>) => (
    <TestItem entity={item} />
  );
<FlatList data={data} renderItem={renderItem} />

--TestItem.tsx render content

<TouchableOpacity
      onPress={handlePress}
      style={{{padding: 10, marginBottom: 10, backgroundColor: 'red'}}>
      <Text>{entity.title}</Text>
      <Text>{collection?.actionType || 'None'}</Text>
    </TouchableOpacity>

In TestItem.tsx, I am querying another table with the entityId information of the entity object I received as a parameter.

const data = useDatabaseData<Collection>(Collection.table, [
    Q.where('entity_id', entity.entityId),
  ]);
  const collection = data?..[0];

there is no problem here either, I have a function in the collection model within the handlePress event and it changes the actionType.

collection.changeActionType(ActionTypes.Wanna);
@writer async changeActionType(
    actionType: ActionTypes | null,
  ): Promise<ActionTypes | null> {
    if (this.actionType === actionType) {
      await this.update(record => {
        record.actionType = ActionTypes.None;
      });

      return null;
    } else if (this.actionType !== actionType) {
      await this.update(record => {
        record.actionType = actionType;
      });

      return actionType;
    }

    await this.database.get<Collection>(this.table).create(record => {
      record.entityId = this.entityId;
      record.actionType = this.actionType;
      record.collectionType = this.collectionType;
      record.userId = this.userId;
    });

    return actionType;
  }

However, even though I do this and the records are actually updated in the db, the collection data in TestItem.tsx is not updated.

My expectation is that where I pull observable records from the db, when the model is updated, the record will be updated everywhere I use the model.

Am I missing something, or does watermelonDb already work like this.