Git Product home page Git Product logo

arbor's People

Contributors

drborges avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar

Forkers

jasperfurniss

arbor's Issues

Observable integration.

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)
});

Use an arbor defined symbol to determine when a value is proxiable

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 {
}

Concurrent rendering

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.

Define Arbor terminologies and definitions.

Answer the following questions:

  • What is Arbor?
  • What Arbor isn't?
  • Who is it for?
  • What are the tradeoffs of using Arbor vs not?
  • What's a state tree?
  • What's structural sharing?
  • Why does Arbor rely on immutability to represent the state of the store?
  • What's a path within Arbor's context?
  • What's an Arbor Node?
  • How do I organize my code?
  • How can I encapsulate business logic?
  • How can I test Arbor-based code?

Simplify @arborjs/react public API

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:

  1. Subscribe to all mutations to the store, e.g.:
const users = useArbor(store)
  1. Subscribe to mutations targeting a specific node within the store, e.g.:
const firstUser = useArbor(store.root[0])
  1. Subscribe to mutations targeting a specific path within the store, e.g.:
const secondUser = useArbor<User>(store, Path.parse("/users/1"))
  1. Subscribe to mutations targeting a path regex pattern, e.g.:
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

Allow user-defined proxy handlers

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.

Fix arrow functions as object properties

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.

Improved subscription model

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:

  1. Create a pubsub abstraction that can encapsulate the subscription / notification logic;
  2. Create an Observable interface (preferably compatible with rxjs) that both the store and Arbor Nodes can implement;
  3. Adjust the mutation handling logic so that it notifies all store subscribers as well as the subscribers listening for changes to the mutation path.

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"
})

Seamless compare node seeds when using Array#includes

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.

Prototype

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
  }

  ...

}

Provide a better mechanism to initialize Arbor stores with async data

In order to simplify initializing a local vs external Arbor store with async data, we propose:

  1. Implementing Arbor#load which sets the store's state to the result of the given promise;
  2. Implement useAsyncArbor which takes:
  3. An "initializer" function that returns a promise whose result is used to initialize the Arbor store;
  4. A dependency list that can be used to recompute the store's state when changed, similar to useEffect dependencies.
  5. Implement useSuspendableArbor which works similarly to useAsyncArbor except it is integrated with Suspense so users don't have to check for loading states.

Thoughts

Without Suspense

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>
  )
}

With Suspense

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>
  )
}

Initialization via props

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.

Simplifying the API

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.

Provide a `isEqual` utility for seamless seed comparison

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

Prototype

function isEqual(v1: object, v2: object) {
  return Seed.from(v1) === Seed.from(v2)
}

Implement React context provider

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[]>()
  ...
}

Improve / polish stitching mechanism

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.

Provide an `id` utility to extract the seed value of a node

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.

Implement docusaurus website

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.

map shorthand - BaseNode#from - TypeError: this is not a constructor

When attempting to shorthand map over BaseNode.from, you receive the following error:
Screenshot 2023-05-25 at 13 08 42

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)
    })
  },
})

Automate package release process

Currently, new package versions are published via npm publish --tolerate-republish.

The plan is to Implement a new Github action that can:

  1. Release a new NPM package version every time a Git tag is created;
  2. Automatically set the tag @next to be the most recent version released;
  3. Create @beta tag when releasing a beta version;
  4. Ship Arbor's first beta version, now that its API is fairly stable.

Using semantic-release may be interesting.

Arbor doesn't re-render if objects do not inherit from BaseNode

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.

Look into ways to support #private field access defined by Nodes

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.

Double-check esm package configuration

I have a feeling the package exports config is not 100% accurate.

Try using Arbor with:

  1. Vite + TypeScript;
  2. CRA + TypeScript;
  3. Webpack + TypeScript + babel;

React hooks

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 (
    ...
  )
}

Create a logo for Arbor

Given that Arbor in Latin means Tree, and Arbor is a state tree management lib, the requirements for the logo would be something like:

  1. Simple;
  2. Resemble a tree structure (because Arbor);
  3. Perhaps similar to the logo of libs like React, Redux, Recoil...

Implement useArborSelector React hook

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.

Deprecate watchers architecture in favor of node tracking mechanism.

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.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.