Fork me on GitHub
#datascript
<
2022-08-04
>
Lidor Cohen18:08:18

Hello everyone! 😃 I'm new here so I'm sorry I'm jumping right into selfish questions 😅 I'm advocating Clojure/Script, datascript and datomic at my company. The way I want to introduce datascript is by using it as our front-end DB. Today our front-end is mainly TS and React so I wonder if anyone knows any good way to integrate DS with react? Today I'm using this snippet I found:

export const useQ = (query, ...args) => {
  const queryArgs = [query, datascript.db(conn), ...args];
  const [state, updateState] = useState(() => datascript.q(...queryArgs));

  const id = uuid();

  useEffect(() => {
    datascript.listen(conn, id, (data) => {
      if (data.tx_data.length) {
        const updatedQueryArgs = [query, data.db_after, ...args];
        const updatedState = datascript.q(...updatedQueryArgs);

        if (!equal(state, updatedState)) {
          updateState(updatedState);
        }
      }
    });
    return () => {
      return datascript.unlisten(conn);
    };
  }, [conn, query, args, id, state]);

  return state;
};
It looked reasonable but for some reason our app exploded (the browser got stuck in a very powerful computer) after a few navigations (the first thing I tried to save into the db was the current Location). Does anyone see any obvious flaws? Or knows any better integrations with react? (I tried homebase BTW but I need the DS db to be accessible outside of the react tree) Sorry for the long first message 😅 And thanks in advance!

Lidor Cohen12:08:25

BTW I found the mistake in the snippet, no key was passed to unlisten. Serve me right, I guess, for using a snippet of the internet without examining it properly.😅 Still investigating use-sync-external-store though, I'll post updates for others with similar issues.

Lidor Cohen11:08:15

I've been at it for an hour or so to understand the api and came up with this:

const dataScriptSubscribe = (listener) => {
   const key = datascript.listen(conn, listener);
   return () => datascript.unlisten(conn, key);
 };
 
 const getQueryData =
   (query, ...args) =>
   () => {
     const queryArgs = [query, datascript.db(conn), ...args];
     return datascript.q(...queryArgs);
   };
 
 export const useQ = (query, ...args) => {
   return useSyncExternalStore(
     dataScriptSubscribe,
     getQueryData(query, ...args)
   );
 };
It seems that the snapshot (in my what returns from the query) should be immutable (for referential equality) but it is not the case and so react blows up into an infinite loop. I'll continue hacking at it, but if you got any pointers that would be awesome 😁

lilactown19:08:57

datascript.q returns a new reference every time it's called

lilactown19:08:26

You'll need to memoize it somehow

Lidor Cohen22:08:15

Thank you! it only took me a second:

const dataScriptSubscribe = (listener) => {
  const key = datascript.listen(conn, listener);
  return () => datascript.unlisten(conn, key);
};

const memoize = moize.deep(R.identity);

const getQueryData =
  (query, ...args) =>
  () => {
    const queryArgs = [query, datascript.db(conn), ...args];
    return memoize(datascript.q(...queryArgs));
  };

export const useQ = (query, ...args) => {
  return useSyncExternalStore(
    dataScriptSubscribe,
    getQueryData(query, ...args)
  );
};
Much cleaner and work like a charm 🙂 Hopefully it'll be useful for others

Lidor Cohen07:08:32

This implementation still seems buggy:

Error: Maximum update depth exceeded. This can happen when a component repeatedly calls setState inside componentWillUpdate or componentDidUpdate. React limits the number of nested updates to prevent infinite loops.

Lidor Cohen07:08:50

The former implementation seems to be more stable so I'm returning to it for now

lilactown08:08:51

Do you want any help debugging?

Lidor Cohen08:08:05

well it seems that I don't fully understand the behavior of useSyncExternalStore . But it's curious that in my use case I have (for now) 2 kinds of queries from the db: 1. Location:

[:find (pull ?location [*]) .
  :where
  [?location "ident" "${locationIdent}"]]
2. Search:
[:find ?search .
  :where
  [?location "ident" "${locationIdent}"]
  [?location "search" ?search]]
Now notice that search is actually just a string that is a part of location. Location is used ubiquitously throughout the app, and nothing breaks. The infinite loop happens only when I use the search query inside a very non-standard component. If I use another hook to get search directly from react-router it works. If I use the fixed useQ snippet (without useSyncExternalStore) it also works. So my hunches currently are: 1. maybe the memoization acts differently for strings? 2. maybe useSyncExternalStore behaviour coupled with the non-standard component causes the issue?

Lidor Cohen08:08:41

An easy test for 2 would be to use search in a standard component that uses location without problem

Lidor Cohen09:08:15

Alright so I tried and search breaks the app regardless of where it called so (thank god) is not an integration issue

Lidor Cohen09:08:24

So my last hunch is memoization of strings, that is the only variable I recognize...

lilactown15:08:06

What lib are you using to memoize it? Ramda?

lilactown16:08:52

what's R.identity?

Lidor Cohen16:08:46

R.identity is x => x Simply returns what it's given...

lilactown16:08:29

could it be that the "search" result returns a different ordering of the results each time, which breaks the memoization approach you're using?

lilactown16:08:55

I'm just guessing, I'd have to see the rest of the code to be sure, maybe run it

Lidor Cohen16:08:35

I'm don't think that datascript.q should be memoized, as memoization works only for referentially transparent functions. If I'd memoized it, the same query would get exactly the same result even though the underlying db has changed.

Lidor Cohen16:08:42

I'll take a deeper look at what search returns each time, also I'll test the memoization function on those results and see if it works as I think

lilactown16:08:35

datascript.q is referentially transparent

lilactown16:08:27

you pass in the query and the datascript.db(conn) each time. if the value returned by datascript.db changes, or the query changes, it would re-run the q function

Lidor Cohen16:08:55

Ohhh yeah, you right, because i wrapped it with my own q i forgot you pass datascript.db(conn)

Lidor Cohen16:08:27

That would be cleaner, I'll try that...

Lidor Cohen17:08:38

I think I found the problem! 😁 I haven't tested it yet, but the max number of cached results default to 1. So it wasn't search that was the problem but the fact that there were 2 queries...

Lidor Cohen17:08:55

I'll test it later with your gist and update

Lidor Cohen18:08:26

WORKS! 🥳 This is the final draft:

const dataScriptSubscribe = (listener) => {
  const key = datascript.listen(conn, listener);
  return () => datascript.unlisten(conn, key);
};

const memoQ = moize(datascript.q, {
  isDeepEqual: true,
  maxSize: Number.POSITIVE_INFINITY,
});

const getQueryData =
  (query, ...args) =>
  () => {
    const queryArgs = [query, datascript.db(conn), ...args];
    return memoQ(...queryArgs);
  };

export const useQ = (query, ...args) => {
  return useSyncExternalStore(
    dataScriptSubscribe,
    getQueryData(query, ...args)
  );
};

Lidor Cohen18:08:11

@U4YGF4NGM Thank you for your help! 😄

lilactown19:08:15

You might consider using an LRU cache instead of an infinite size to avoid memory consumption. Not sure if moize supports that. But I'm glad it's working!!

Lidor Cohen15:08:56

moize does LRU, I wasn't sure what size to give it :thinking_face:. I'm guessing it should be around o(n) where n is the size of concurrent queries.