Skip to content

Provide a better mechanism to initialize Arbor stores with async data #114

@drborges

Description

@drborges

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions