drborges / arbor Goto Github PK
View Code? Open in Web Editor NEWA fully typed reactive state management library with very little boilerplate.
License: MIT License
A fully typed reactive state management library with very little boilerplate.
License: MIT License
Try leveraging https://github.com/krausest/js-framework-benchmark for benchmarking Arbor perf.
See symbol-observable and Redux's implementation.
Here's how we could easily create an Observable
from an Arbor
store:
const store = new Arbor(...)
const observable = new Observable(function subscribe(subscriber) {
// returns Arbor's unsubscribe function so that the observer
// can unsubscribe from the observable
return store.subscribe((newState, oldState, mutationPath) => {
subscriber.next({
newState,
oldState,
mutationPath,
})
})
});
observable.subscribe(({ newState, oldState, mutationPath }) => {
console.log("mutationPath:", mutationPath)
console.log("newState:", newState)
console.log("oldState:", oldState)
});
We could simplify things by providing a helper function to hide some of that complexity away:
// fromArbor.ts
export default function fromArbor<T extends object>(store: Arbor<T>) {
return new Observable(function subscribe(subscriber) {
// returns Arbor's unsubscribe function so that the observer
// can unsubscribe from the observable
return store.subscribe((newState, oldState, mutationPath) => {
subscriber.next({
newState,
oldState,
mutationPath,
})
})
});
}
Then using it:
const store = new Arbor(...)
const observable = fromArbor(store)
observable.subscribe(({ newState, oldState, mutationPath }) => {
console.log("mutationPath:", mutationPath)
console.log("newState:", newState)
console.log("oldState:", oldState)
});
In order to more easily determine if a given value is proxiable by Arbor, user defined data types will have to set the proxiable symbol prop to hint to Arbor’s runtime that any instance of that type is proxiable by Arbor’s standards.
Example:
import { proxiable } from “@arborjs/store”
class User {
[proxiable]: true
}
Extending from ArborNode
will automatically make the type proxiable:
import { ArborNode } from “@arborjs/store”
class User extends ArborNode {
}
The @arborjs/plugins
package currently defines idb
as a peer dependency required when using the experimental IndexedDB persistency plugin. It should also be marked as optional since not every user consuming the plugins package will need IndexedDB persistency.
Check how well/poorly will Arbor work in concurrent rendering (React 18). This project should give some direction as to how to test for this scenario.
I have a feeling that currently Arbor will suffer from tearing since it is a mutable external store, perhaps we may have to bring back immutability to Arbor's core.
Configure Husky to lint and run tests before any push to a remote branch.
Answer the following questions:
Currently, the repository contains a few different experimental hooks such as useArborState
, useArborValue
, useArborNode
, etc...
It would be ideal if we could make the public API as simple as possible in order to reduce the learning curve and keep the lib free of unnecessary boilerplate.
If we could stick to a single React hook to cover all use-cases then devs need to learn only one API. For instance, we could make useArbor
flexible enough to take different arguments in order to achieve the various use cases. Example:
type Target<T extends object> = Arbor<T> | Node<T> | T
type Selector<T extends object, K extends object> = (state: T) => K
function useArbor<
T extends object,
K extends object = T,
>(target: Target<T>, selectorOrPath: Path | Selector<T, k>)
This would allow for use-cases like, the following:
Give the following store:
interface User {
id: number
name: string
}
const store = new Arbor<User[]>([
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
{ id: 3, name: "Carol" },
])
Then:
const users = useArbor(store)
const firstUser = useArbor(store.root[0])
const secondUser = useArbor<User>(store, Path.parse("/users/1"))
const anyUser = useArbor<User>(store, /\/users\/[0-9]+$/)
// Or
const anyUser = useArbor<User>(store, "/users/[0-9]+$")
Note that a path works similarly to an object dot notation: users.1
Is equivalent to /users/1
I’m still not sure if it’s worth keeping useArborState
and useArborValue
…
This should streamline the release process and make it so sematic versioning is followed thoroughly.
This will allow Arbor to leverage React's async mode.
See reactwg/react-18#86 (comment)
This article is a great reference on how to approach the integration.
It may be interesting to allow users to customize the proxying behavior of Arbor, enabling them to register new Proxy handler implementations to address specific use cases. Image something like the following:
class MyNodeArrayHandler<T extends object> extends NodeHandler<T[]> {
// Checks if the given target value can be proxied by this NodeHandler implementation.
static accepts(target: unknown) {
return Array.isArray(target)
}
// Overrides NodeHandler's default implementation
get(target, prop, receiver) {
// custom logic
}
// Overrides NodeHandler's default implementation
set(target, prop, newValue, receiver) {
// custom logic
}
}
const store = new Arbor({ ... })
store.register(MyNodeArrayHandler)
Newly registered node handlers are prepended to a list of ProxyHandler strategies. When the state tree is traversed, for each node visited, Arbor will look for the first NodeHandler in that list that can handle the given node.
Currently, Arbor is not binding arrow functions defined as object props (class methods) to the reactive proxy, causing mutations triggered by these arrow functions to not notify subscribers.
Currently, subscriptions are notified about every state change in the store even if they are only interested in changes to specific paths within the state tree.
It would be interesting if clients could subscribe to specific paths within the state three and only be notified when those paths change. This would reduce the number of notifications Arbor needs to process should an app have more subscribers that are not listening to all mutations.
The idea is:
Example:
const store = new Arbor({
users: {
"user-1": { uuid: "user-1", name: "Alice" },
"user-2": { uuid: "user-2", name: "Bob" }
},
todos: {
"todo-1": { uuid: "todo-1", text: "Do the dishes", completed: false, ownerId: "user-1" },
"todo-2": { uuid: "todo-2", text: "Walk the dogs", completed: false, ownerId: "user-2" },
}
})
const unsubscribe = store.subscribe((newStoreState, oldStoreState, mutationPath) => {
// React to the store change
})
const unsubscribe = store.subscribeTo(store.root.todos, (newTodosState, oldTodosState, mutationPath) => {
// Only react to changes to the collection of todos
})
const unsubscribe = store.subscribeTo(store.root.todos["todo-2"], (newTodoState, oldTodoState, mutationPath) => {
// Only react to changes to Todo id = "todo-2"
})
Because Arbor nodes are proxies around data and even though for instances of Arbor
, the underlying data is mutable the proxies wrapping nodes in the OST (observable state tree) get "refreshed" every time the underlying data is mutated. This is done so that we can easily compute diffs on the OST, by simply comparing the identity of nodes, e.g. ===
.
However, this behavior can create some unexpected behavior. For instance, take the following store holding an array of todos:
const store = new Arbor([
{ text: "todo 1" },
{ text: "todo 2" },
])
Should we get a reference of say, todo 1
and mutate it like so:
const todo1 = store.state[0]
todo1.text = "todo 1 updated"
Then the identity of the variable todo1
will no longer be the same as store.state[0]
since that node in the OST will be refreshed to indicate it has been mutated, so the following will be false:
todo1 === store.state[0]
=> false
Similarly, Array#includes
would return unexpected results, for example:
store.state.includes(todo1)
=> false
Even though todo1
points to something valid within the OST.
I believe we can provide a better DX if we extend Array#includes
as part of the overwrites in ArrayNodeHandler
so that it automatically compares the Seed
value of the values being compared, this would enabled users to rely on Array#includes
for this type of check.
export class ArrayNodeHandler<T extends object = object> extends NodeHandler<
T[]
> {
...
includes(value: T) {
for (const item of this) {
if (Seed.from(item) === Seed.from(value)) return true
}
return false
}
...
}
In order to simplify initializing a local vs external Arbor store with async data, we propose:
Arbor#load
which sets the store's state to the result of the given promise;useAsyncArbor
which takes:useEffect
dependencies.useSuspendableArbor
which works similarly to useAsyncArbor
except it is integrated with Suspense so users don't have to check for loading states.Without suspense, the DX would be similar to what it is commonly used by data fetching libraries out there.
The example below shows how that could look like when initializing a local Arbor store:
function TodoListView({ userId }: { userId: string }) {
const { data: todos, loading, error } = useAsyncArbor(() => fetch(`/${userId}/todos`), [userId])
if (error) // handler error state
if (loading) // handle loading state
return (
<ul>
{todos.map(todo => <TodoView todo={todo} />)}
</ul>
)
}
function TodoView({ todo }: { todo: Todo }) {
return (
<div>{todo.content}</div>
)
}
A similar pattern could be used to initialize an external store with data resolved within the application's context:
// External store initialized somewhere in the application, outside React's lifecycle
const store = new Arbor(new TodoList())
function TodoListView({ userId }: { userId: string }) {
const { data: todos, loading, error } = useAsyncArbor(() => store.load(fetch(`/${userId}/todos`), [userId])
if (error) // handler error state
if (loading) // handle loading state
return (
<ul>
{todos.map(todo => <TodoView uuid={todo.uuid} key={todo.uuid} />)}
</ul>
)
}
function TodoView({ uuid }: { uuid: string }) {
// todoByUuid is a selector function (plain JS function) that is used to select a specific state tree node to subscribe to
const todo = useArbor(todoByUuid(uuid))
return (
<div>{todo.content}</div>
)
}
When running within a suspense compatible React version, the pattern used is similar, but with less boilerplate since there will be no need for devs to explicitly check for loading states within their component logic.
This is what it would look like when initializing a local store:
function TodoListView({ userId }: { userId: string }) {
// Throws suspense promise
// Needs a suspense boundary around the component
const todos = useSuspendableArbor(() => fetch(`/${userId}/todos`), [userId])
return (
<ul>
{todos.map(todo => <TodoView todo={todo} />)}
</ul>
)
}
function TodoView({ todo }: { todo: Todo }) {
return (
<div>{todo.content}</div>
)
}
And when initializing an external store:
const store = new Arbor(new TodoList())
function TodoListView({ userId }: { userId: string }) {
const todos = useSuspendableArbor(() => store.load(fetch(`/${userId}/todos`), [userId])
return (
<ul>
{todos.map(todo => <TodoView uuid={todo.uuid} key={todo.uuid} />)}
</ul>
)
}
function TodoView({ uuid }: { uuid: string }) {
const todo = useArbor(todoByUuid(uuid))
return (
<div>{todo.content}</div>
)
}
Since the initialization logic only expects a promise to resolve with the store's initial state, we can leverage the same mechanism in order to feed the store with data coming via props.
Changing the previous local store example to take the initial store data via props, we'd have:
function TodoListView({ initialTodos }: { todos: Todo[] }) {
const todos = useArbor(initialTodos)
return (
<ul>
{todos.map(todo => <TodoView todo={todo} />)}
</ul>
)
}
function TodoView({ todo }: { todo: Todo }) {
return (
<div>{todo.content}</div>
)
}
Similarly, we can initialize an external store via props:
const store = new Arbor(new TodoList())
function TodoListView({ initialTodos }: { todos: Todo[] }) {
// The store initialization process happens only once and does not trigger a re-render.
const todos = useArbor(store.initialize(initialTodos))
return (
<ul>
{todos.map(todo => <TodoView uuid={todo.uuid} key={todo.uuid} />)}
</ul>
)
}
function TodoView({ uuid }: { uuid: string }) {
const todo = useArbor(todoByUuid(uuid))
return (
<div>{todo.content}</div>
)
}
Initialize the store via props can be handy when working with SSR frameworks such as Remix or NextJs, where one can leverage the power of SSR to resolve the data from the backend while allowing having a client-side store that can provide the reactivity needed to build interesting UX solutions.
In order to maintain Arbor core values, we could look into making the API footprint as small as possible, provinding a single useArbor
hook with overloaded behavior based on the arguments passed to it, so that devs don't have to learn multiple hooks in order to accomplish their tasks.
Because Arbor nodes are proxies around data and even though for instances of Arbor
, the underlying data is mutable the proxies wrapping nodes in the OST (observable state tree) get "refreshed" every time the underlying data is mutated. This is done so that we can easily compute diffs on the OST, by simply comparing the identity of nodes, e.g. ===
.
However, this behavior can create some unexpected behavior. For instance, take the following store holding an array of todos:
const store = new Arbor([
{ text: "todo 1" },
{ text: "todo 2" },
])
Should we get a reference of say, todo 1
and mutate it like so:
const todo1 = store.state[0]
todo1.text = "todo 1 updated"
Then the identity of the variable todo1
will no longer be the same as store.state[0]
since that node in the OST will be refreshed to indicate it has been mutated, so the following will be false:
todo1 === store.state[0]
=> false
Providing a utility function to allow users to compare nodes by their seeds can provide a similar behavior as the identity check mentioned above:
isEqual(todo1, store.state[0])
=> true
function isEqual(v1: object, v2: object) {
return Seed.from(v1) === Seed.from(v2)
}
In some cases it may be convenient to be able to share a store via context provider while keeping the rest of the application decoupled from how and where the store comes from:
const store1 = new Arbor<Todo[]>([])
const store2 = new Arbor<Todo[]>([])
function App1() {
return (
<Provider value={store1}>
<MyApp />
</Provider>
)
}
function App2() {
return (
<Provider value={store2}>
<MyApp />
</Provider>
)
}
function MyApp() {
const todos = useArborContext<Todo[]>()
...
}
Arbor is meant to make it easier for devs to create multiple stores within the same app and potentially persist them in different locations. However, it is still useful to be able to treat the application state as a single store, that's where stitching comes in. Take the following scenario as a quick example:
import Arbor, { Collection, stitch } from "@arborjs/react"
interface User {
uuid: string
name: string
}
interface Post {
uuid: string
content: string
authorId: string
}
interface AppState {
users: Collection<User>
posts: Collection<Post>
}
const userStore = new Arbor(new Collection<User>())
const postStore = new Arbor(new Collection<Post>())
// We can easily combine all of the different stores into a single one
// by stitching them together.
//
// The resulting store reacts to changes to all of the stitched stores
// and changes to the `appStore` are also propagated to the
// underlying stores.
const appStore = stitch<AppState>({
users: userStore,
posts: postStore,
})
// Plugins that need to interact with all stores can now be added
// directly to the app store.
appStore.use(persistencePlugin)
Since this stitching logic was still experimental, it has not been thoroughly tested. In one of my observations, it seems that apps using stiched stores end up with redundant re-rendering cycles, which isn't ideal.
In front-end apps, often the need for a UUID comes up to be able to uniquely identify values in the absence of a backend DB ID.
Since Arbor already assigns a unique Seed
value to nodes so it can track them across mutations (even when using ImmutableArbor
that applies structural sharing to the underlying data), the idea is to expose that value so users can use it to uniquely identify values in their application state without the need to create UUIDs.
Example:
import { id } from "@arborjs/store"
const store = new Arbor([
{ text: "todo 1" },
{ text: "todo 2" },
])
const arrayId = id(store.state)
=> 0
const todo1Id = id(store.state[0])
=> 1
const todo2Id = id(store.state[1])
=> 2
This can be handy in React apps to use as keys to components.
TODO
docusaurus looks easy enough and quite flexible with MDX support. This should help get an initial documentation website up and running fairly quickly.
The scope of this work is simply set up Docusaurus and a few example pages, the actual documentation will be written in a separate issue.
When attempting to shorthand map
over BaseNode.from
, you receive the following error:
Occurs when:
[{ uuid: "asd", text: "my text", completed: false }].map(BaseNode.from)
Does not occur when:
[{ uuid: "asd", text: "my text", completed: false }].map(item => BaseNode.from(item))
An example of where I'm using this is in the persistance deserializer:
export const persistence = new LocalStorage<Todos>({
key: "todoist.todos",
debounceBy: 300,
deserialize: (items) => {
const todos = Object.values(items?.todos || {}).map(Todo.from)
return Todos.from<Todos>({
todos: new Repository(...todos)
})
},
})
Currently, new package versions are published via npm publish --tolerate-republish
.
The plan is to Implement a new Github action that can:
@next
to be the most recent version released;Using semantic-release may be interesting.
Arbor should at least warn the user if they are attempting to store objects that do not inherit from BaseNode
, because if they do not, Arbor will not be aware of their location in the tree and thus not re-render when expected. Additionally, a lot of the built in functionality being written into the docs will be unavailable.
By default, #private fields are not forwarded by proxies in JS. That's because a proxy is technically still another object that cannot access private context within another object.
There's a chance this will be a limitation in Arbor where devs may have to shy away from #private fields, or when using TypeScript rely on TS's private access modifier since after compilation these don't become #private fields.
However, if there's a way we can provide a workaround so Arbor can support #private fields seamlessly or in a simple way (again, the whole point of Arbor is to have a very thin API so devs can mostly rely on their JS knowledge) that'd be ideal.
Worst case scenario we'll just document this as one of the tradeoffs of using Arbor.
I have a feeling the package exports config is not 100% accurate.
Try using Arbor with:
The package @arborjs/react
will expose a few hooks that would allow users to better manage local state and state tree stores. Here're the initial thoughts:
class User {
id: string
firstName: string
lastName: string
age: number
constructor(attributes: Partial<User> = {}) {
Object.assign(this, attributes)
}
get fullName() {
return `${this.firstName} ${this.lastName}`
}
}
const store = new Arbor<User[]>([])
function MyComponent() {
// Exposes the store data or initial state as a proxied / reactive object.
// Mutations to this object or any nested path within the object
// don't take effect on the object itself, but rather, trigger mutations
// against the store, which will then compute the resulting next state by
// applying structural sharing.
const $users = useArbor(store | initialState)
// Exposes a read-only version of the store state,
// Mutations in this case must be computed using the store itself and
// can be abstracted in terms of helper functions, in this context, you can
// think of them as serving a similar purpose as Redux actions, e.g., notify
// the store of a series of actions that should affect the store state in some way.
// Example:
// const createUser = (data: Partial<User>) => store.root.push(new User(data))
// const deleteUser = (user: User) => {
// const index = store.root.findIndex(u => u.id === user.id)
// delete store.root[index]
// }
const users = useArborValue(store)
// This exposes an interface similar to React's useState hook,
// returning a pair of read-only state, and an update function
// that takes a mutation function as argument, this function receives the
// current root node of the underlying store and mutations can be applied
// directly to that object, as well as mutations to more deeply nested paths
// within the object, Arbor will take care of generating the next state
// accordinly and notify all components subscribed to the store.
const [users, setUsers] = useArborState(initialState)
return (
...
)
}
Given that Arbor in Latin means Tree, and Arbor is a state tree management lib, the requirements for the logo would be something like:
Details coming soon.
See this codesandbox for a reproducible test.
This would simplify path comparison logic.
In some cases, it's convenient to bind a React component to a specific portion of the state tree or to the result of a state tree computation rather than a concrete node. The concept of a selector seems to fit well this scenario:
type Todo = {
id: number
text: string
}
const store = new Arbor<Todo[]>([
{ id: 1, text: "Do the dishes" },
{ id: 2, text: "Clean the house" },
])
function App() {
const todosCount = useArborSelector(store, todos => todos.length)
...
}
In the example above, the App
component would only re-render if the todos count changes.
Watchers were built in order to provide flexibility as to when a React component is supposed to "react" (pun intended) to certain mutations to the node the component is bound to.
This puts on the developer the burden on deciding which logic is needed for which use-cases. However, in 99% of the use-cases, users will expect a component to react should any of the props from a state tree node they rely on change.
In order to provide an even better DX (less boilerplate and concepts to master), this issue proposes deprecating the watcher architecture and in favor of a simpler useArbor
hook API which internally will keep track of prop accesses within the context of a React component, automatically reacting to mutations targeting the tracked props and ignoring mutations on props not being used by the component.
A proof-of-concept was built in this codesandbox.
A declarative, efficient, and flexible JavaScript library for building user interfaces.
🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.
TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
An Open Source Machine Learning Framework for Everyone
The Web framework for perfectionists with deadlines.
A PHP framework for web artisans
Bring data to life with SVG, Canvas and HTML. 📊📈🎉
JavaScript (JS) is a lightweight interpreted programming language with first-class functions.
Some thing interesting about web. New door for the world.
A server is a program made to process requests and deliver data to clients.
Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.
Some thing interesting about visualization, use data art
Some thing interesting about game, make everyone happy.
We are working to build community through open source technology. NB: members must have two-factor auth.
Open source projects and samples from Microsoft.
Google ❤️ Open Source for everyone.
Alibaba Open Source for everyone
Data-Driven Documents codes.
China tencent open source team.