diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 68d44a16..cbf458a1 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -8,7 +8,8 @@ - [ ] no tests needed ### Docs -- [ ] added relevant docs +- [ ] added relevant docs + - preview them at https://homebase.io/docs/homebase-react/{BRANCH_NAME}/overview - [ ] updated relevant sections in the README.md - [ ] updated relevant docstrings in index.d.ts - [ ] no docs needed @@ -16,3 +17,10 @@ ### Typescript - [ ] added or edited relevant Typescript type declarations - [ ] no type declaration updates needed + +## Merging +For maintainers. + +To merge, select "Squash and Merge". Then: + 1. Make sure the top commit message follows [Angular Git Commit Guidelines](https://github.com/angular/angular.js/blob/master/DEVELOPERS.md#-git-commit-guidelines), + 2. Delete all other commit messages in the description, but keep any lines designating [co-authors](https://docs.github.com/en/free-pro-team@latest/github/committing-changes-to-your-project/creating-a-commit-with-multiple-authors) so contributors will retain credit for their contributions. diff --git a/.gitignore b/.gitignore index eef28bac..cf6f1beb 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ pom.xml.asc .hgignore .hg/ +.vscode diff --git a/.npmignore b/.npmignore index ff5a7b79..a5d298a4 100644 --- a/.npmignore +++ b/.npmignore @@ -31,8 +31,8 @@ pom.xml.asc .hg/ examples/ - - types/ - src/ - public/ - js/ +types/ +src/ +public/ +js/ +docs/ diff --git a/README.md b/README.md index 9d5f4556..3c84dfa4 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,10 @@ npm install homebase-react --save # Yarn yarn add homebase-react ``` + +## Docs +https://homebase.io/docs/homebase-react + ## Features - The simplest and most declarative state management solution - The power of a backend relational graph database, but without having to wait on the network @@ -103,7 +107,7 @@ const RootComponent = () => ( ### `useEntity` and `entity.get` -Entities are the building blocks of the Homebase data model. They are like JSON objects with bonus features. In particular **you can traverse arbitrarily deep relationship without actually denormalizing and nesting your data**. +Entities are the building blocks of the Homebase data model. They are like JSON objects with bonus features. In particular **you can traverse arbitrarily deep relationships without actually denormalizing and nesting your data**. ```js // You can get an entity by its id and get attributes off of it. @@ -185,107 +189,6 @@ This hook returns the current database client with some helpful functions for sy Check out the [Firebase example](https://homebaseio.github.io/homebase-react/#!/example.todo_firebase) for a demonstration of how you might integrate a backend. -### Arrays & Nested JSON - -Arrays and arbitrary JSON are partially supported for convenience. However in most cases its better to avoid arrays. Using a query and then sorting by an attribute is simpler and more flexible. This is because arrays add extra overhead to keep track of order. - -```js -const config = { - schema: { - company: { - numbers: { type: 'ref', cardinality: 'many' }, - projects: { type: 'ref', cardinality: 'many' }, - } - } -} - -transact([ - { project: { id: -1, name: 'a' } }, - { - company: { - numbers: [1, 2, 3], - projects: [ - { project: { id: -1 } }, - { project: { name: 'b' } }, - ] - } - } -]) - -// Index into arrays -company.get('numbers', 1, 'value') // => 2 -company.get('projects', 0, 'ref', 'name') // => 'a' -// Get the automatically assigned order -// Order starts at 1 and increments by 1 -company.get('numbers', 0, 'order') // => 1 -company.get('projects', 0, 'order') // => 1 -company.get('projects', 1, 'order') // => 2 -// Map over individual attributes -company.get('numbers', 'value') // => [1, 2, 3] -company.get('projects', 'ref', 'name') // => ['a', 'b'] -``` - -The `entity.get` API is flexible and supports indexing into arrays as well as automatically mapping over individual attributes. - -Array items are automatically assigned an `order` and either a `value` or a `ref` depending on if item in the array is an entity or not. To reorder an array item change its `order`. - -```js -transact([ - { - id: company.get('numbers', 2, 'id'), - order: (company.get('numbers', 0, 'order') - + company.get('numbers', 1, 'order')) / 2 - } -]) - -company.get('numbers', 'value') // => [1 3 2] -``` - -If you need to transact complex JSON like arrays of arrays then you're better off serializing it to a string first. - -```js -// NOT supported -transact([{ company: { matrix: [[1, 2, 3], [4, 5, 6]] } }]) - -// Better -transact([{ company: { matrix: JSON.stringify([[1, 2, 3], [4, 5, 6]]) } }]) -JSON.parse(company.get('matrix')) -``` - -## Performance - -Homebase React tracks the attributes consumed in each component via the `entity.get` function and scopes those attributes to their respective `useEntity` or `useQuery` hook. Re-renders are only triggered when an attribute changes. - -The default caching reduces unnecessary re-renders and virtual DOM thrashing a lot. That said, it is still possible to trigger more re-renders than you might want. - -One top level `useQuery` + prop drilling the entities it returns will cause all children to re-render on any change to the parent or their siblings. - -To fix this we recommend passing ids to children, not whole entities. Instead get the entity in the child with `useEntity(id)`. This creates a new scope for each child so they are not affected by changes in the state of the parent or sibling components. - -```js -const TodoList = () => { - const [todos] = useQuery({ - $find: 'todo', - $where: { todo: { name: '$any' } } - }) - return (todos.map(t => )) -} - -// Good -const Todo = React.memo(({ id }) => { - const [todo] = useEntity(id) - // ... -}) - -// Bad -const Todo = React.memo(({ todo }) => { - // ... -}) -``` - - -## Docs -https://www.notion.so/Homebase-Alpha-Docs-0f0e22f3adcd4e9d87a13440ab0c7a0b ## Development ```bash diff --git a/docs/0100|Overview.md b/docs/0100|Overview.md new file mode 100644 index 00000000..aae05f46 --- /dev/null +++ b/docs/0100|Overview.md @@ -0,0 +1,40 @@ +## Homebase React + +[![CI](https://github.com/homebaseio/homebase-react/workflows/CI/badge.svg)](https://github.com/homebaseio/homebase-react/actions?query=workflow%3ACI) +[![CD](https://github.com/homebaseio/homebase-react/workflows/CD/badge.svg)](https://github.com/homebaseio/homebase-react/actions?query=workflow%3ACD) +[![NPM Version](https://img.shields.io/npm/v/homebase-react)](https://www.npmjs.com/package/homebase-react) +[![Bundle Size](https://img.shields.io/bundlephobia/minzip/homebase-react)](https://www.npmjs.com/package/homebase-react) +[![License](https://img.shields.io/github/license/homebaseio/homebase-react.svg)](LICENSE) +[![GitHub Repo stars](https://img.shields.io/github/stars/homebaseio/homebase-react?style=social)](https://github.com/homebaseio/homebase-react) +[![Twitter Follow](https://img.shields.io/twitter/follow/homebase__io?label=Follow&style=social)](https://twitter.com/homebase__io) + +*The graph database for delightful React state management* + + +Homebase React makes state management painless by enabling you to plug a relational graph database into your React application with just 3 lines of code. This is the same database that powers Roam Research and many other ClojureScript applications, but with an API that's familiar to React and JS developers. + +## Install + +```bash +# NPM +npm install homebase-react --save + +# Yarn +yarn add homebase-react +``` +## Features +- The simplest and most declarative state management solution +- The power of a backend relational graph database, but without having to wait on the network +- Convenient JSON query syntax +- Powerful Clojure style [Datalog](https://docs.datomic.com/on-prem/query.html) query syntax if you need it +- Traverse your data graph like it's a big JSON object +- Backup your data to the cloud + +## Roadmap + +1. Document integration with more backends +1. Swap [Datascript](https://github.com/tonsky/datascript) out for [Datahike](https://github.com/replikativ/datahike) + 1. Immutability + 1. History / Change Tracking +2. Persist to IndexedDB +3. [Local-first](https://www.inkandswitch.com/local-first.html) conflict resolution for offline caching and sync between multiple devices \ No newline at end of file diff --git a/docs/0200|Quick_Start.md b/docs/0200|Quick_Start.md new file mode 100644 index 00000000..5883e40a --- /dev/null +++ b/docs/0200|Quick_Start.md @@ -0,0 +1,36 @@ +Homebase React creates a local relational database for your React app. + +```js +import { HomebaseProvider } from 'homebase-react' + +const RootComponent = () => ( + + + +) +``` + +Read from and write to that database via hooks. + +```js +import { useCallback } from 'react' +import { useEntity, useTransact } from 'homebase-react' + +const App = () => { + const [counter] = useEntity(1) + const [transact] = useTransact() + + const handleClick = useCallback(() => { + transact([{ counter: { + id: 1, count: counter.get('count') + 1 + } }]) + }, [counter, transact]) + + return ( + + ) +} +``` \ No newline at end of file diff --git a/docs/0300|Tutorial.md b/docs/0300|Tutorial.md new file mode 100644 index 00000000..2eb467ec --- /dev/null +++ b/docs/0300|Tutorial.md @@ -0,0 +1,593 @@ +This tutorial takes you through our [Todo Example](https://homebaseio.github.io/homebase-react/#!/example.todo). + +## HomebaseProvider + +Let's get started. + +`HomebaseProvider` is a component that wraps your React app and creates a local relational database. This database is then accessible to any child components via React Hooks. + +```jsx +import React from 'react' +import { HomebaseProvider, useTransact, useQuery, useEntity } from 'homebase-react' + +export const App = () => { + return ( + + + + ) +} +``` + +## Schema + +Unlike other state managers, Homebase does not try to create yet another design pattern for state. Instead, we store state in a way we already know and love: as a relational graph database. + +Like any good database we support schema on read. + +At the moment schema is only for relationships and uniqueness constraints. It does not support typing of attributes, e.g. strings, integers, dates. We're working on adding the option to opt into schema on write support. This will provide basic type checking like you see in SQL. + +```jsx +const schema = { + project: { + name: { + unique: 'identity' + } + }, + todo: { + // refs are relationships + project: { + type: 'ref' + }, + owner: { + type: 'ref' + } + } +} +``` + +## Initial Data + +Hydrate your application with initial data. Here we add an initial user, project, and todo as well as the todoFilter that will filter the initial state to show todos that have been completed. + +This data is a transaction that runs on database creation to seed your DB. + +```jsx +const initialData = [ + { + user: { + // negative numbers can be used as temporary ids in a transaction + id: -1, + name: 'Stella' + } + }, { + project: { + id: -3, + name: 'To the stars' + } + }, { + todo: { + name: 'Fix ship', + owner: -1, + project: -3, + isCompleted: true, + createdAt: new Date('2003/11/10') + } + }, { + todoFilter: { + // identity is a special attribute for user generated ids + // E.g. this is a setting that should be easy to lookup by name + identity: 'todoFilters', + showCompleted: true, + project: 0 + } + } +] +``` + +## Config + +And now we're ready to go. 馃殌 + +```jsx +const config = { + schema, + initialData +} +``` + +## Reading and Writing Data + +Use the todoFilters we added earlier to filter the view. With Homebase everything is just data. It's similar to a reducer in React Hooks or Redux, but without the need to write bespoke mutation functions. We also introduce our `useEntity` and `useTransact` React Hooks here. + +`useEntity` enables you to grab the todoFilters Entity directly from Homebase by its `identity`(a unique developer given name, like a custom id). Entities are the building blocks of the Homebase data model. They are like JSON objects with bonus features: you can traverse arbitrarily deep relationship without actually denormalizing and nesting your data. + +`useTransact` lets you `transact` state from any component. Transactions let you create, update and delete multiple entities simultaneously and atomically. All changes will reactively update any components that depend on the changed data. + +```jsx +const TodoFilters = () => { + const [filters] = useEntity({ identity: 'todoFilters' }) + const [transact] = useTransact() + return ( +
+ + transact([{ todoFilter: { id: filters.get('id'), showCompleted: e.target.checked }}])} + /> +  路  + transact([{ todoFilter: { id: filters.get('id'), project }}])} + /> +
+ ) +} +``` + +## Entity and Transact Examples + +In the following example `todo` is a Homebase database entity being passed in as a prop. + +As you probably noticed entities have a convenient function `entity.get('attribute')`. It's like `jsObj['attribute']` but with a lot of bonus benefits: + +1. You can chain attributes to traverse your relational graph + - `todo.get('project', 'owners', 0, 'name') => 'Stella'` +1. Chaining attributes that return undefined will return null instead of an error + - `jsObj.nullAttr.childAttr => Error` + - `entity.get('nullAttr', 'childAttr') => null` +1. Caching is built in. Homebase tracks the attributes used by every component and only triggers re-renders when that specific data changes. This caching is scoped to our hooks, so while we're passing a `todo` entity in the following example it can be better to pass the `id` as a prop and then `const [todo] = useEntity(id)` in the component to create a new caching scope. + +```jsx +const Todo = ({ todo }) => ( +
+
+ + +
+
+ +  路  + +  路  + +
+ + {todo.get('createdAt').toLocaleString()} + +
+) +``` + +Notice how `id: todo.get('id')` is used in the following example to update an existing todo entity. + +```jsx +const TodoCheck = ({ todo }) => { + const [transact] = useTransact() + return ( + transact([{ + todo: { + id: todo.get('id'), + isCompleted: e.target.checked + } + }])} + /> + ) +} + +``` + +`useTransact` is incredibly convenient. It lets you create, update, and delete any state from any component. + +```jsx +const TodoName = ({ todo }) => { + const [transact] = useTransact() + return ( + transact([{ todo: { id: todo.get('id'), name: e.target.value }}])} + /> + ) +} + +const TodoProject = ({ todo }) => { + const [transact] = useTransact() + return ( + transact([{ todo: { id: todo.get('id'), 'project': projectId || null }}])} + /> + ) +} +``` + +## Queries + +`TodoOwner` introduces our first instance of the `useQuery` React Hook. Query the relational database directly in your component using Javascript friendly syntax. Queries return an array of unique entities instead of an individual entity like `useEntity`. + +```jsx +const TodoOwner = ({ todo }) => { + const [transact] = useTransact() + const [users] = useQuery({ + $find: 'user', + $where: { user: { name: '$any' } } + }) +... +``` + +Here's the full `TodoOwner` component: + +```jsx +const TodoOwner = ({ todo }) => { + const [transact] = useTransact() + const [users] = useQuery({ + $find: 'user', + $where: { user: { name: '$any' } } + }) + return ( + <> + +   + + + ). +``` + +Our query API is powered by Datalog, and exposed as JSON similar to a JS SQL driver or MongoDB. Datalog is a subset of Prolog, a logic programming language, but with a few features restricted so it is guaranteed to terminate. This is not a perfect metaphor, but you can think of it as a more powerful version of SQL. + +We don't go into Datalog queries here, instead easing you into our JSON query API for simplicity, but you can directly query `Homebase` with Datalog as well. If you're interested in writing more sophisticated queries you can pass a datalog string instead of a JSON query as the first argument to useQuery. + +**E.g.** +```jsx +const [users] = useQuery(` + [:find ?todo + :where [?todo :todo/owner ?user] + [?user :user/name "Stella"]] +`) +``` + +This will return all todos owned by users named Stella. As you can see joins are implicit by using the `?user` variable in multiple `:where` clauses. + +We won't get into more detail about Datalog here since it's essentially a programming language. The syntax we use has it's roots in [Clojure](https://clojure.org/) and [Datomic](https://www.datomic.com/on-prem.html). If you'd like us to priortize documention for these advanced queries please [let us know](https://github.com/homebaseio/homebase-react/issues?q=is%3Aissue+datalog). In the meantime we recommend [Learn Datalog Today](http://www.learndatalogtoday.org/) as a good place to start. + +### Create Data + +It's all just transactions. Yes it's repetitive, but the goal of Homebase it to make data declarative and composable on the client and the server. This means providing a powerful core library and letting you combine the pieces to declare what you want, without needing to say how to do achieve it. + +```jsx +const NewTodo = () => { + const [transact] = useTransact() + return ( +
{ + e.preventDefault() + transact([{ + todo: { + name: e.target.elements['todo-name'].value, + createdAt: new Date + } + }]) + e.target.reset() + }}> + +   + +
+ ) +} +``` + +### Delete Data + +The object style transactions support deletion of individual attributes by setting them to null. +```jsx +transact({ todo: { id: 123, name: null } }) +``` + +To delete entire entities we provide a database function `retractEntity`. Database functions are atomic functions that run inside of transactions, basically they're reducers. Database functions are invoked by passing an array with the function name first followed by arguments `['retractEntity', 123]`. + +```jsx +const TodoDelete = ({ todo }) => { + const [transact] = useTransact() + return ( + + ) +} +``` + +## The Full Example + +Here's everything we covered. You can try the app for yourself [here](https://homebaseio.github.io/homebase-react/#!/example.todo). + +If you're interested in integrating a backend you can check out our Firebase example [here](https://homebaseio.github.io/homebase-react/#!/example.todo_firebase) for inspiration or ping us at hi@homebase.io or in our [message board](https://github.com/homebaseio/homebase-react/discussions) to pair on integrating a custom backend. + +```jsx +import React from 'react' +import { HomebaseProvider, useTransact, useQuery, useEntity } from 'homebase-react' + +export const App = () => { + return ( + + + + ) +} + +const config = { + // Schema is only used to enforce + // unique constraints and relationships. + // It is not a type system, yet. + schema: { + project: { name: { unique: 'identity' } }, + todo: { + // refs are relationships + project: { type: 'ref' }, + owner: { type: 'ref' } + } + }, + // Initial data let's you conveniently transact some + // starting data on DB creation to hydrate your components. + initialData: [ + { + user: { + // negative numbers can be used as temporary ids in a transaction + id: -1, + name: 'Stella' + } + }, { + project: { + id: -3, + name: 'To the stars' + } + }, { + todo: { + name: 'Fix ship', + owner: -1, + project: -3, + isCompleted: true, + createdAt: new Date('2003/11/10') + } + }, { + todoFilter: { + // identity is a special attribute for user generated ids + // E.g. this is a setting that should be easy to lookup by name + identity: 'todoFilters', + showCompleted: true, + project: 0 + } + } + ] +} + +const Todos = () => { + return ( +
+ + + +
+ ) +} + +const NewTodo = () => { + const [transact] = useTransact() + return ( +
{ + e.preventDefault() + transact([{ + todo: { + name: e.target.elements['todo-name'].value, + createdAt: new Date() + } + }]) + e.target.reset() + }}> + +   + +
+ ) +} + +const TodoList = () => { + const [filters] = useEntity({ identity: 'todoFilters' }) + const [todos] = useQuery({ + $find: 'todo', + $where: { todo: { name: '$any' } } + }) + return ( +
+ {todos.filter(todo => { + if (!filters.get('showCompleted') && todo.get('isCompleted')) return false + if (filters.get('project') && todo.get('project', 'id') !== filters.get('project')) return false + if (filters.get('owner') && todo.get('owner', 'id') !== filters.get('owner')) return false + return true + }).sort((a, b) => a.get('createdAt') > b.get('createdAt') ? -1 : 1) + .map(todo => )} +
+ ) +} + +// PERFORMANCE: By accepting an `id` prop instead of a whole `todo` entity +// this component stays disconnected from the useQuery in the parent TodoList. +// useEntity creates a separate scope for every Todo so changes to TodoList +// or sibling Todos don't trigger unnecessary re-renders. +const Todo = React.memo(({ id }) => { + const [todo] = useEntity(id) + return ( +
+
+ + +
+
+ +  路  + +  路  + +
+ + {todo.get('createdAt').toLocaleString()} + +
+ ) +}) + +const TodoCheck = ({ todo }) => { + const [transact] = useTransact() + return ( + transact([{ todo: { id: todo.get('id'), isCompleted: e.target.checked } }])} + /> + ) +} + +const TodoName = ({ todo }) => { + const [transact] = useTransact() + return ( + transact([{ todo: { id: todo.get('id'), name: e.target.value }}])} + /> + ) +} + +const TodoProject = ({ todo }) => { + const [transact] = useTransact() + return ( + transact([{ todo: { id: todo.get('id'), project }}])} + /> + ) +} + +const TodoOwner = ({ todo }) => { + const [transact] = useTransact() + return ( + transact([{ todo: { id: todo.get('id'), owner }}])} + /> + ) +} + +const TodoDelete = ({ todo }) => { + const [transact] = useTransact() + return ( + + ) +} + +const TodoFilters = () => { + const [filters] = useEntity({ identity: 'todoFilters' }) + const [transact] = useTransact() + return ( +
+ +  路  + transact([{ todoFilter: { id: filters.get('id'), project }}])} + /> +  路  + transact([{ todoFilter: { id: filters.get('id'), owner }}])} + /> +
+ ) +} + +const EntitySelect = React.memo(({ label, entityType, value, onChange }) => { + const [entities] = useQuery({ + $find: entityType, + $where: { [entityType]: { name: '$any' } } + }) + return ( + + ) +}) +``` + +## Thanks! + +Thanks for trying us out! We're excited to see what you build and would love to hear any feedback. \ No newline at end of file diff --git a/docs/0350|---.md b/docs/0350|---.md new file mode 100644 index 00000000..e69de29b diff --git a/docs/0400|API.md b/docs/0400|API.md new file mode 100644 index 00000000..3cf99773 --- /dev/null +++ b/docs/0400|API.md @@ -0,0 +1,205 @@ +## `HomebaseProvider` + +The HomebaseProvider wraps your React app and makes a relational database accessible to all of your components. Configure it with `schema` and `initialData`. + +```js +import { HomebaseProvider, useEntity, useTransact, useQuery } from 'homebase-react' + +const config = { + // Schema is not a type system, + // it's a way to simplify relational queries at query time. + // The schema currently supported is: + // `type: 'ref'` which is a relationship and + // `unique: 'identity` which enforces a uniqueness constraint + // and lets you lookup entities by their unique attributes. + schema: { + todo: { + project: { type: 'ref', cardinality: 'one' }, + name: { unique: 'identity' } + } + }, + + // Initial data is what it sounds like. + // It's a transaction that runs on component mount. + // Use it to hydrate your app. + initialData: [ + { project: { id: -1, name: 'Do it', user: -2 } }, + { todo: { project: -1, name: 'Make it' } }, + { user: { id: -2, name: 'Arpegius' } } + ] + + // Or relationships can be specified implicitly with nested JSON + initialData: [ + { + todo: { + name: 'Make it', + project: { + name: 'Do it', + user: { + name: 'Arpegius' + } + } + } + } + ] +} + +const RootComponent = () => ( + + + +) +``` + +## `useEntity` and `entity.get` + +Entities are the building blocks of the Homebase data model. They are like JSON objects with bonus features. In particular **you can traverse arbitrarily deep relationships without actually denormalizing and nesting your data**. + +```js +// You can get an entity by its id and get attributes off of it. +const [todo] = useEntity(2) +todo.get('id') // => 2 +todo.get('name') // => 'Make it' + +// Entities with unique attributes can also be retrieved by those attributes. +const [sameTodo] = useEntity({ todo: { name: 'Make it' } }) +sameTodo.get('id') // => 2 + +// And most importantly you can traverse arbitrarily deep relationships. +sameTodo.get('project', 'user', 'name') // => 'Arpegius' +``` + +## `useTransact` + +Transactions let you create, update and delete multiple entities simultaneously. All changes will reactively update any components that depend on the changed data. + +```js +const transact = useTransact() + +// A transaction is an array of nested objects and or arrays. +// Leaving the id blank will create a new entity. +transact([{ todo: { name: 'New Todo', project: 1 } }]) + +// Setting the id to a negative number is a temp id which +// allows multiple entities to be related to each other on creation. +transact([ + { project: { id: -123, name: 'New Project' } }, + { todo: { project: -123, name: 'New Todo' } }, +]) + +// Update an entity by including its id. +// NOTE: that only the included attributes will be updated. +transact([{ project: { id: 1, name: 'Changed Project Title' } }]) + +// To remove an attribute you have to explicitly set it to null. +transact([{ project: { id: 1, name: null } }]) + +// To delete an entire entity use retractEntity and its id +transact([['retractEntity', 1]]) +``` + +## `useQuery` + +Use queries to return an array of entities that meet a given criteria. Our query API is powered by Datalog, but exposed as JSON similar to a JS SQL driver or MongoDB. Datalog is similar to SQL and is incredibly powerful. However, only a subset of features are currently available in JSON. + +We will prioritize features based on community feedback so please open an issue if there's something you need. In the meantime you can further filter results with JS `filter()` and `sort()`. + +```js +// Finds all todos with a name +const [todos] = useQuery({ + $find: 'todo', + $where: { todo: { name: '$any' } } +}) + +// Returns an array of todo entities +todos +.sort((todo1, todo2) => todo1.get('name') > todo2.get('name') ? 1 : -1) +.map(todo => todo.get('name')) +``` + +## `useClient` + +This hook returns the current database client with some helpful functions for syncing data with a backend. + +- `client.dbToString()` serializes the whole db including the schema to a string +- `client.dbFromString('a serialized db string')` replaces the current db +- `client.dbToDatoms()` returns an array of all the facts aka datoms saved in the db + - datoms are the smallest unit of data in the database, like a key value pair but better + - they are arrays of `[entityId, attribute, value, transactionId, isAddedBoolean]` +- `client.addTransactListener((changedDatoms) => ...)` adds a listener function to all transactions + - use this to save data to your backend +- `client.removeTransactListener()` removes the transaction listener + - please note that only 1 listener can be added per useClient scope +- `client.transactSilently([{item: {name: ...}}])` like `transact()` only it will not trigger any listeners + - use this to sync data from your backend into the client + +Check out the [Firebase example](https://homebaseio.github.io/homebase-react/#!/example.todo_firebase) for a demonstration of how you might integrate a backend. + +## Arrays & Nested JSON + +Arrays and arbitrary JSON are partially supported for convenience. However in most cases its better to avoid arrays. Using a query and then sorting by an attribute is simpler and more flexible. This is because arrays add extra overhead to keep track of order. + +```js +const config = { + schema: { + company: { + numbers: { type: 'ref', cardinality: 'many' }, + projects: { type: 'ref', cardinality: 'many' }, + } + } +} + +transact([ + { project: { id: -1, name: 'a' } }, + { + company: { + numbers: [1, 2, 3], + projects: [ + { project: { id: -1 } }, + { project: { name: 'b' } }, + ] + } + } +]) + +// Index into arrays +company.get('numbers', 1, 'value') // => 2 +company.get('projects', 0, 'ref', 'name') // => 'a' +// Get the automatically assigned order +// Order starts at 1 and increments by 1 +company.get('numbers', 0, 'order') // => 1 +company.get('projects', 0, 'order') // => 1 +company.get('projects', 1, 'order') // => 2 +// Map over individual attributes +company.get('numbers', 'value') // => [1, 2, 3] +company.get('projects', 'ref', 'name') // => ['a', 'b'] +``` + +The `entity.get` API is flexible and supports indexing into arrays as well as automatically mapping over individual attributes. + +Array items are automatically assigned an `order` and either a `value` or a `ref` depending on if item in the array is an entity or not. To reorder an array item change its `order`. + +```js +transact([ + { + id: company.get('numbers', 2, 'id'), + order: (company.get('numbers', 0, 'order') + + company.get('numbers', 1, 'order')) / 2 + } +]) + +company.get('numbers', 'value') // => [1 3 2] +``` + +If you need to transact complex JSON like arrays of arrays then you're better off serializing it to a string first. + +```js +// NOT supported +transact([{ company: { matrix: [[1, 2, 3], [4, 5, 6]] } }]) + +// Better +transact([{ company: { matrix: JSON.stringify([[1, 2, 3], [4, 5, 6]]) } }]) +JSON.parse(company.get('matrix')) +``` + +For more information check out the [JSON Derived Relationships blog post](https://homebase.io/blog/homebase-react-0.5.0-json-derived-relationships) diff --git a/docs/0650|---.md b/docs/0650|---.md new file mode 100644 index 00000000..e69de29b diff --git a/docs/0700|Performance.md b/docs/0700|Performance.md new file mode 100644 index 00000000..b45f5958 --- /dev/null +++ b/docs/0700|Performance.md @@ -0,0 +1,46 @@ +Homebase React tracks the attributes consumed in each component via the `entity.get` function and scopes those attributes to their respective `useEntity` or `useQuery` hook. Re-renders are only triggered when an attribute changes. + +The default caching reduces unnecessary re-renders and virtual DOM thrashing a lot. That said, it is still possible to trigger more re-renders than you might want. + +## Smart Prop Drilling + +One top level `useQuery` + prop drilling the entities it returns will cause all children to re-render on any change to the parent or their siblings. + +To fix this we recommend passing ids to children, not whole entities. Instead get the entity in the child with `useEntity(id)`. This creates a new scope for each child so they are not affected by changes in the state of the parent or sibling components. + +### Good Prop Drilling + +```js +const TodoList = () => { + const [todos] = useQuery({ + $find: 'todo', + $where: { todo: { name: '$any' } } + }) + return (todos.map(t => )) +} + +const Todo = React.memo(({ id }) => { + const [todo] = useEntity(id) + // ... +}) +``` + +### Bad Prop Drilling + +```js +const TodoList = () => { + const [todos] = useQuery({ + $find: 'todo', + $where: { todo: { name: '$any' } } + }) + return (todos.map(t => )) +} + +const Todo = React.memo(({ todo }) => { + // ... +}) +``` + +## React Hooks Performance + +If you're looking for more optimizations check out the [React docs on optimizing hooks](https://reactjs.org/docs/hooks-faq.html#performance-optimizations). \ No newline at end of file diff --git a/docs/0800|Examples.md b/docs/0800|Examples.md new file mode 100644 index 00000000..ba4b841a --- /dev/null +++ b/docs/0800|Examples.md @@ -0,0 +1,4 @@ +Want to see homebase-react in action? Take a look at the examples. + +- [Live Examples](https://homebaseio.github.io/homebase-react/) +- [Examples Repo](https://github.com/homebaseio/homebase-react/tree/master/examples) \ No newline at end of file diff --git a/package.json b/package.json index 0f5a7b92..acb4d382 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "main": "./dist/js/homebase.react.js", "private": false, "scripts": { - "dev": "shadow-cljs watch dev", + "dev": "shadow-cljs watch dev & babel js --out-dir src/js_gen --watch && kill $!", "build": "rm -rf dist && shadow-cljs release npm && yarn bundle-ts", "build:dev": "rm -rf dist && shadow-cljs compile npm && yarn bundle-ts", "test:js": "yarn build && jest js/tests && yarn tsd", @@ -94,5 +94,6 @@ "author": "Chris Smothers (https://homebase.io)", "contributors": [ "JB Rubinovitz (https://homebase.io)" - ] + ], + "dependencies": {} } diff --git a/src/homebase/js.cljs b/src/homebase/js.cljs index 9650c8fd..0b490222 100644 --- a/src/homebase/js.cljs +++ b/src/homebase/js.cljs @@ -7,6 +7,8 @@ [inflections.core :refer [singular]] [datascript.impl.entity :as de])) +(def ^:dynamic *debug* false) + (defn keywordize-str [s] (if (and (string? s) (= (subs s 0 1) ":")) (keyword (subs s 1)) @@ -222,7 +224,7 @@ (reduced (namespace k)))) nil (keys entity))) -(defn js-get [entity name] +(defn js-get [^de/Entity entity name] (case name "id" (:db/id entity) "ident" (:db/ident entity) @@ -231,121 +233,132 @@ k (when maybe-ns (js->key maybe-ns name))] (when k (get entity k))))) -(defn entity-in-db? [entity] - (not (nil? (first (d/datoms (.-db entity) :eavt (:db/id entity)))))) - -(declare HBEntity - humanize-get-error - humanize-transact-error - humanize-entity-error - humanize-q-error) - -(defn Entity->HBEntity [v] - (if (= de/Entity (type v)) - (HBEntity. v nil) v)) +(declare + Entity + humanize-get-error + humanize-transact-error + humanize-entity-error + humanize-q-error) + +(defn new-entity + ([d-entity] (new-entity d-entity nil)) + ([d-entity meta] + (Entity. + d-entity meta (:db/id d-entity) (:db/ident d-entity) + (when-let [type (guess-entity-ns d-entity)] + (csk/->camelCase type))))) + +(defn entity-in-db? [^de/Entity d-entity] + (when d-entity + (not (nil? (first (d/datoms (.-db d-entity) :eavt (:db/id d-entity))))))) (defmulti entity->js "If the entity is a set (cardinality/many) then put it in a JS array" - (fn [entity] (type entity))) -(defmethod entity->js :default [entity] entity) -(defmethod entity->js PersistentHashSet [entity-set] + (fn [meta entity] + (type entity))) +(defmethod entity->js :default [_ v] v) +(defmethod entity->js de/Entity [meta ^de/Entity d-entity] + (when d-entity (new-entity d-entity meta))) +(defmethod entity->js PersistentHashSet [meta entity-set] (->> entity-set (sort-by :homebase.array/order) + (map (partial entity->js meta)) to-array)) -(defn lookup-entity +(defn humanize-error + "Attempts to rewrite any errors to be more JS friendly" + [error-humanize-f f] + (if *debug* + (f) + (try + (f) + (catch js/Error e + (throw (js/Error. (error-humanize-f e))))))) + +(defn any-entity->d-entity [entity] + (if (instance? Entity entity) (.-_entity entity) entity)) + +(defn lookup-entity + "Takes a homebase.js/Entity and a seq of attributes. Looks up the attribute path on the entity. Returns a scalar or homebase.js/Entity or js/Array of scalars or Entities." ([entity attrs] (lookup-entity entity attrs false)) - ([entity attrs nil-attrs-if-not-in-db?] - (try - (Entity->HBEntity + ([entity attrs nil-attrs-if-not-in-db?] (lookup-entity entity attrs nil-attrs-if-not-in-db? nil)) + ([entity attrs nil-attrs-if-not-in-db? get-cb] + (humanize-error + #(humanize-get-error % entity) + (fn [] (reduce (fn [acc attr] - (if-not acc nil - (let [attr (keywordize attr) - getter-fn (if (keyword? attr) get js-get) - getter-fn (comp entity->js getter-fn)] - (cond - (array? acc) (if (number? attr) - (nth acc attr) - (.map acc #(getter-fn % attr))) - (and nil-attrs-if-not-in-db? - (or (= :db/id attr) (= "id" attr)) - (not (entity-in-db? acc))) nil - acc (getter-fn acc attr) - :else nil)))) - entity attrs)) - (catch js/Error e - (throw (js/Error. (humanize-get-error e entity))))))) + (if-not acc + nil + (let [attr (keywordize attr) + getter-fn (if (keyword? attr) get js-get) + getter-fn (comp (partial entity->js {:Entity/get-cb get-cb}) + getter-fn) + result (cond + (array? acc) (if (number? attr) + (nth acc attr) + (.map acc #(getter-fn (any-entity->d-entity %) attr))) + (and nil-attrs-if-not-in-db? + (or (= :db/id attr) (= "id" attr)) + (not (entity-in-db? (any-entity->d-entity acc)))) nil + acc (getter-fn (any-entity->d-entity acc) attr) + :else nil)] + result))) + entity attrs))))) (extend-type de/Entity Object - (get [entity & attrs] (lookup-entity entity attrs))) + (get ^{:deprecated "0.5.1" + :superseded-by "homebase.js/Entity.prototype.get()"} + [entity & attrs] + (lookup-entity (Entity. entity nil nil nil nil) attrs))) -(deftype HBEntity [^de/Entity entity _meta] +(deftype Entity [^de/Entity _entity _meta id _ident type] IMeta (-meta [_] _meta) IWithMeta - (-with-meta [_ new-meta] (HBEntity. entity new-meta)) + (-with-meta [_ new-meta] (Entity. _entity new-meta id _ident type)) ILookup - (-lookup [_ attr] (lookup-entity entity [attr] true)) - (-lookup [_ attr not-found] (or (lookup-entity entity [attr] true) not-found)) + (-lookup [this attr] (lookup-entity this [attr] true)) + (-lookup [this attr not-found] (or (lookup-entity this [attr] true) not-found)) IAssociative - (-contains-key? [_ k] (not (nil? (lookup-entity entity [k] true)))) + (-contains-key? [this k] (not (nil? (lookup-entity this [k] true)))) Object - (get [this attrs] - (when (seq attrs) - (let [v (lookup-entity entity attrs true)] - (when-let [f (:HBEntity/get-cb (meta this))] - (f [this attrs v])) - v)))) - -(defn ^{:jsdoc ["@nocollapse"]} Entity [^de/Entity d-entity] - (this-as ^Entity this - (set! (.-id this) (:db/id d-entity)) - (set! (.-type this) - (when-let [type (guess-entity-ns d-entity)] - (csk/->camelCase type))) - (when-let [ident (:db/ident d-entity)] - (set! (.-_ident this) ident)) - (set! (.-_entity this) (HBEntity. d-entity nil)) - this)) - -(set! (.. Entity -prototype -get) - (fn [& entityAttributeName] - (this-as ^Entity this - (.get (.-_entity this) entityAttributeName)))) + (get [this & attrs] + (let [get-cb (:Entity/get-cb (meta this)) + v (lookup-entity this attrs true get-cb)] + (when get-cb (get-cb [this attrs v])) + v))) (defn q-entity-array [query conn & args] (->> (apply d/q query conn args) (map (fn id->entity [[id]] - (Entity. (d/entity conn id)))) + (new-entity (d/entity conn id) nil))) to-array)) (defn transact! ([conn tx] (transact! conn tx nil)) ([conn tx tx-meta] - (try - (d/transact! conn (js->tx (:schema @conn) tx) tx-meta) - (catch js/Error e - (throw (js/Error. (humanize-transact-error e))))))) + (humanize-error + humanize-transact-error + #(d/transact! conn (js->tx (:schema @conn) tx) tx-meta)))) (defn entity [conn lookup] - (try - (Entity. (d/entity @conn (js->entity-lookup lookup))) - (catch js/Error e - (throw (js/Error. (humanize-entity-error e)))))) + (humanize-error + humanize-entity-error + #(new-entity (d/entity @conn (js->entity-lookup lookup)) nil))) (defn q [query conn & args] - (try - (apply q-entity-array (js->query query) @conn (keywordize args)) - (catch js/Error e - (throw (js/Error. (humanize-q-error e)))))) + (humanize-error + humanize-q-error + #(apply q-entity-array (js->query query) @conn (keywordize args)))) (defn humanize-get-error [error entity] (condp re-find (goog.object/get error "message") #"(?:(.+) is not ISeqable|Cannot use 'in' operator to search for 'db' in (.+))" :>> (fn [[_ v1 v2]] - (let [key (ffirst (filter (fn [[_ v]] (= (or v1 v2) (str v))) entity)) + (let [d-entity ^de/Entity (.-_entity entity) + key (ffirst (filter (fn [[_ v]] (= (or v1 v2) (str v))) d-entity)) nmspc (namespace key) attr (name key)] (str "The `" nmspc "." attr "` attribute should be marked as ref if you want to treat it as a relationship." diff --git a/src/homebase/js_test.cljs b/src/homebase/js_test.cljs index 81b9581a..74dfd2e5 100644 --- a/src/homebase/js_test.cljs +++ b/src/homebase/js_test.cljs @@ -113,8 +113,8 @@ (is (= "abc" (.get (d/entity @test-conn 3) "project" "name")))) (testing "homebase entity get" (is (some? (hbjs/entity (d/create-conn) 3))) - (is (= 3 (:db/id ^hbjs/HBEntity (.-_entity (hbjs/entity test-conn 3))))) - (is (= 3 (get ^hbjs/HBEntity (.-_entity (hbjs/entity test-conn 3)) "id"))) + (is (= 3 (:db/id (hbjs/entity test-conn 3)))) + (is (= 3 (get (hbjs/entity test-conn 3) "id"))) (is (= 3 (.get (hbjs/entity test-conn 3) "id"))) (is (nil? (.get (hbjs/entity (d/create-conn) 3) "name"))) (is (= "abc" (.get (hbjs/entity test-conn 2) "name"))) @@ -126,8 +126,8 @@ (is (nil? (get (hbjs/entity (d/create-conn) 3) "id"))) (is (nil? (get-in (hbjs/entity (d/create-conn) 3) ["id"]))) (is (nil? (.get (hbjs/entity (d/create-conn) 3) "project" "id"))) - (is (= 2 (get-in ^hbjs/HBEntity (.-_entity (hbjs/entity test-conn 3)) ["project" "id"]))) - (is (= "abc" (get-in ^hbjs/HBEntity (.-_entity (hbjs/entity test-conn 3)) ["project" "name"]))) + (is (= 2 (get-in (hbjs/entity test-conn 3) ["project" "id"]))) + (is (= "abc" (get-in (hbjs/entity test-conn 3) ["project" "name"]))) (testing "arrays" (is (= [1 2 "c"] (js->clj (.map (.get (hbjs/entity test-conn 7) "array") #(.get % "value"))))) (is (= "c" (.get (hbjs/entity test-conn 7) "array" 2 "value"))) diff --git a/src/homebase/react.cljs b/src/homebase/react.cljs index 2d93c663..a9993486 100644 --- a/src/homebase/react.cljs +++ b/src/homebase/react.cljs @@ -4,37 +4,66 @@ [clojure.string] [cljs.reader] [goog.object] + [clojure.set] [homebase.js :as hbjs] [datascript.core :as d] [datascript.impl.entity :as de])) (defn try-hook [hook-name f] - (try (f) - (catch js/Error e - (throw - (js/Error. - (condp re-find (goog.object/get e "message") - #"No protocol method IDeref.-deref defined for type undefined" - "HomebaseProvider context unavailable. must be declared by a parent component before homebase-react hooks can be used." - (str (goog.object/get e "message") "\n" - (some->> (goog.object/get e "stack") - (re-find (re-pattern (str hook-name ".*\\n(.*)\\n?"))) - (second) - (clojure.string/trim))))))))) + (if hbjs/*debug* + (f) + (try (f) + (catch js/Error e + (throw + (js/Error. + (condp re-find (goog.object/get e "message") + #"No protocol method IDeref.-deref defined for type undefined" + "HomebaseProvider context unavailable. must be declared by a parent component before homebase-react hooks can be used." + (str (goog.object/get e "message") "\n" + (some->> (goog.object/get e "stack") + (re-find (re-pattern (str hook-name ".*\\n(.*)\\n?"))) + (second) + (clojure.string/trim)))))))))) -(defn changed? [entities cached-entities] - (if (not= (count entities) (count cached-entities)) - true +(defn debug-msg [return-value & msgs] + (when (and (number? hbjs/*debug*) (>= hbjs/*debug* 2)) + (apply js/console.log "%c homebase-react " "background: yellow" msgs)) + return-value) + +(defn changed? [entities cached-entities track-count?] + #_(js/console.log ">>" + track-count? + #js {:entities (clj->js entities) + :cache (clj->js cached-entities)}) + (if (and track-count? (not= (count entities) (count cached-entities))) + (debug-msg true "cache:miss" "count of entities != cache" + #js {:entities (clj->js entities) + :cache (clj->js cached-entities)}) (reduce (fn [_ e] - (let [e ^hbjs/HBEntity (.-_entity e)] - (when (let [cached-e (get cached-entities (get e "id"))] - (if (nil? cached-e) - (reduced true) - (reduce (fn [_ [ks v]] - (when (not= v (get-in e ks)) - (reduced true))) - nil cached-e))) - (reduced true)))) + (when (let [cached-e (get cached-entities (get e "id"))] + (if (nil? cached-e) + (reduced (debug-msg true "cache:miss" "not in cache" + #js {:entity-id (get e "id") + :entities (clj->js entities) + :cache (clj->js cached-entities)})) + (reduce (fn [_ [ks old-v]] + (let [new-v (get-in e ks)] + (when (and (not= 0 (compare old-v new-v)) + ;; Ignore Entities and arrays of Entities + (not (or (instance? hbjs/Entity new-v) + (and (array? new-v) + (= (count new-v) (count old-v)) + (instance? hbjs/Entity (nth new-v 0)))))) + (reduced (debug-msg true "cache:miss" "value changed" + #js {:entity-id (get e "id") + :attr-path (clj->js ks) + :e e + :old-v old-v + :new-v new-v + :entities (clj->js entities) + :cache (clj->js cached-entities)}))))) + nil cached-e))) + (reduced true))) nil entities))) (defn cache->js [entity cached-entities] @@ -45,21 +74,22 @@ #js {} (get @cached-entities (get entity "id")))) (defn touch-entity-cache [entity cached-entities] - (set! ^js/Object (.-_recentlyTouchedAttributes entity) #js {}) - (set! ^hbjs/HBEntity (.-_entity entity) - (vary-meta - ^hbjs/HBEntity (.-_entity entity) merge - {:HBEntity/get-cb - (fn [[e ks v]] - (if (get e "id") - (do - (swap! cached-entities assoc-in [(get e "id") ks] v) - (set! ^js/Object (.-_recentlyTouchedAttributes entity) - (cache->js e cached-entities))) - (do - (reset! cached-entities {}) - (set! ^js/Object (.-_recentlyTouchedAttributes entity) #js {}))))})) - entity) + (let [get-cb (fn [[e ks v]] + (if (get e "id") + (do + (swap! cached-entities assoc-in [(get e "id") ks] v) + (when hbjs/*debug* + (set! ^js/Object (.-_recentlyTouchedAttributes entity) + (cache->js e cached-entities)))) + (do + (reset! cached-entities {}) + (when hbjs/*debug* + (set! ^js/Object (.-_recentlyTouchedAttributes entity) #js {}))))) + _ (when hbjs/*debug* (set! ^js/Object (.-_recentlyTouchedAttributes entity) #js {})) + ; Use (set! ...) instead of (vary-meta) to preserve the reference to the original entity + ;; entity (vary-meta entity merge {:Entity/get-cb get-cb}) + _ (set! ^hbjs/Entity (.-_meta entity) {:Entity/get-cb get-cb})] + entity)) (defn datom-select-keys [d] #js [(:e d) (str (:a d)) (:v d) (:tx d) (:added d)]) @@ -83,11 +113,14 @@ :db/cardinality :db.cardinality/one}}) (defn ^:export HomebaseProvider [props] - (let [conn (d/create-conn (if-let [schema (goog.object/getValueByKeys props #js ["config" "schema"])] + (let [schema (goog.object/getValueByKeys props #js ["config" "schema"]) + initial-tx (goog.object/getValueByKeys props #js ["config" "initialData"]) + debug (goog.object/getValueByKeys props #js ["config" "debug"]) + _ (when debug (set! hbjs/*debug* debug)) + conn (d/create-conn (if schema (merge (hbjs/js->schema schema) base-schema) base-schema))] - (when-let [tx (goog.object/getValueByKeys props #js ["config" "initialData"])] - (hbjs/transact! conn tx)) + (when initial-tx (hbjs/transact! conn initial-tx)) (react/createElement (goog.object/get homebase-context "Provider") #js {:value conn} @@ -96,29 +129,18 @@ (defn ^:export useClient [] (let [conn (react/useContext homebase-context) key (react/useMemo rand #js []) - client #js {"dbToString" (react/useCallback - #(pr-str @conn) - #js []) - "dbFromString" (react/useCallback - #(do (reset! conn (cljs.reader/read-string %)) - (d/transact! conn [] ::silent)) - #js []) - "dbToDatoms" (react/useCallback - #(datoms->js (d/datoms @conn :eavt)) - #js []) - ;; "dbToJSON" (react/useCallback - ;; #(clj->js (datoms->json (d/datoms @conn :eavt))) - ;; #js []) - "transactSilently" (react/useCallback - (fn [tx] (try-hook "useClient" #(hbjs/transact! conn tx ::silent))) - #js []) - "addTransactListener" (react/useCallback - (fn [listener-fn] (d/listen! conn key #(when (not= ::silent (:tx-meta %)) - (listener-fn (datoms->js (:tx-data %)))))) - #js []) - "removeTransactListener" (react/useCallback - #(d/unlisten! conn key) - #js [])}] + client (react/useMemo + (fn [] + #js {"dbToString" #(pr-str @conn) + "dbFromString" #(do (reset! conn (cljs.reader/read-string %)) + (d/transact! conn [] ::silent)) + "dbToDatoms" #(datoms->js (d/datoms @conn :eavt)) + ;; "dbToJSON" #(clj->js (datoms->json (d/datoms @conn :eavt))) + "transactSilently" (fn [tx] (try-hook "useClient" #(hbjs/transact! conn tx ::silent))) + "addTransactListener" (fn [listener-fn] (d/listen! conn key #(when (not= ::silent (:tx-meta %)) + (listener-fn (datoms->js (:tx-data %)))))) + "removeTransactListener" #(d/unlisten! conn key)}) + #js [])] [client])) (defn ^:export useEntity [lookup] @@ -134,7 +156,7 @@ listener (react/useCallback (fn entity-listener [] (let [result (run-lookup)] - (when (changed? #js [result] @cached-entities) + (when (changed? #js [result] @cached-entities false) (setResult result)))) #js [run-lookup])] (react/useEffect @@ -150,14 +172,16 @@ cached-entities (react/useMemo #(atom {}) #js []) run-query (react/useCallback (fn run-query [] - (.map (try-hook "useQuery" #(apply hbjs/q query conn args)) - (fn [e] (touch-entity-cache e cached-entities)))) + (let [result (try-hook "useQuery" #(apply hbjs/q query conn args))] + (when (not= (count result) (count @cached-entities)) + (reset! cached-entities {})) + (.map result (fn [e] (touch-entity-cache e cached-entities))))) #js [query args]) [result setResult] (react/useState (run-query)) listener (react/useCallback (fn query-listener [] (let [result (run-query)] - (when (changed? result @cached-entities) + (when (changed? result @cached-entities true) (setResult result)))) #js [run-query])] (react/useEffect