-
Notifications
You must be signed in to change notification settings - Fork 26
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
useObservables for react hooks #16
Comments
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 However, I did have success with this |
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 |
I know! I think it's best to post it anyway, and then figure out what's the best name… I imagine |
@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 |
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. |
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 It's a bit tricky to share code because I can't load everything into CodeSandbox .. but let me know if you spot anything! |
I've just had a look at this again with fresh eyes and realise it is the removal of the 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 |
This seems very similar to how react-apollo-hooks works! |
FYI! I'm working on a fully supported, tested & high-performance solution for hooks :) This is not easy, because:
here's some snippets of the work I'm doing. Basic 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 But you'd still have to wrap it somewhere with a 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? |
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 Thanks! I've seen that thread and |
Just want to point out we have |
@FredyC Thanks! I'll check it out! |
observable-hooks supports Observables and Suspense which I think is super handy to use in conjunction with withObservables. |
@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... |
That would be great! Much appreciated🍻. |
Hi there. Any news on this? |
@gliesche Not much! I recently got back to looking at it, and implemented an |
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 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... |
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 FWIW we're moving away from RxJS internally for performance reasons, but yes, the outside API is RxJS-compatible) |
@crimx I guess I don't get the API right, I tried something like:
Which results in a TypeError: |
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. |
The version of the metro bundler is being used: |
Just started to adopt WatermelonDB in my RN App. I understand that we need to host observables logic on components level (which unlocks conditional rendering), but what I don't like is that HOC enforces me to split some UI nodes into separate component just to allow it's wrapping. So instead i used <ObservablesContainer deps={[]} getObservables={() => ({ some: interval$(1000) })}>
{(props) => <Text>{JSON.stringify(props)}</Text>}
</ObservablesContainer> So now @radex based on your WatermelonDB API design experience do you have immediate concerns on such approach? |
Any update? |
Hey! This issue has been here for a while... I am still interested on using react hooks wih watermelondb, so I just would like to share an other approach for local first data https://riffle.systems/essays/prelude/ . It's called Riffle, it is not publicly available yet, but it would make possible a super fast reactive synchronous data hook, that can maybe serve as inspiration for new ideas in this project. |
We need hooks for watermelon db please |
We need hooks asap pls |
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; |
@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? |
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. |
@bensenescu react query is basically a memory cache, and there's a |
@832bb9 Could you give an example? |
@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. |
@832bb9 Thanks for the quick response. I am trying to modify your first example with
which gives me
I also tried it like this
which gives me
Is there a way to observe a complete table? |
@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... |
@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.
|
@jessep Thanks for your answer. If I implement it like this
I get
|
I have this hook
Which I use the following
Now, what I actually need is something comparable to According to the documentation you are supposed to use
You seem to know your way around observables? |
@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);
}); |
@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
It does not I am halfway there, because this
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 |
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
} Screen.Recording.2024-05-23.at.15.52.04.mov |
@crimx Perfect, this works like a charm! Thanks a lot for sticking with me! |
@crimx Do you know how to observe not a complete table, but only a record based on a condition? If I had this query
how would I write it with observables? |
Sorry I am not familiar with advanced watermelondb API. Base on the code you posted, are you looking for something like |
@Stophface you need to use 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. const post = useObservableEagerState(
useObservable(
(inputs$) => inputs$.pipe(switchMap$(([postId]) => database.get('posts').findAndObserve(postId)),
[postId],
),
) But it will work only with |
@crimx |
I just came up with an example that contains a query :)
I get
|
This is because 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
} |
@832bb9 Thanks again. This works :)! |
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? |
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. |
Hey all, I've found
withObservables
to be super handy, but now with react hooks I've been using a simple "useObservables" instead:Usage:
Note: it is best to use ObservableBehavior so that the
observable.value
can be accessed synchronously for the first renderThe text was updated successfully, but these errors were encountered: