vellajs / vella

MIT License
15 stars 3 forks source link

Built-in State Management Proposal #18

Open JAForbes opened 4 years ago

JAForbes commented 4 years ago

Assumptions

  1. Vanilla state management is difficult in vella because S isn't intuitive.
  2. Vella benefits from granular stream updates
  3. Due to 2. single state objects compromise Vella's performance value proposition
  4. Any state management approach we use must be optimized for 2.

Solutions

  1. We should borrow from some of my research on queries+streams.
  2. We can heavily simplify the API for the specific use case of state management within vella.
  3. Query+Streams uses proxy property access so it should feel more "native", and it can be typescript compatible in a similar way to monolite

Examples

// v.boot will provide access to a state query stream
v.boot(document.body, ({ state }) => {

  // you can read the state by invoking it:
  console.log( state() )

  // you can write to the state by invoking it with a parameter
  state( newState )

  // you can transform the state by passing in a transform function
  state( prevState => f(prevState) )

  // you can access sub properties of the state with normal property access
  const firstUser = state.users[0]

  // `firstUser` has the exact same api as `state`
  console.log( firstUser() )
  firstUser( newUser )
  firstUser( prevUser => f(prevUser )

  // Both `state` and `firstUser` are S streams that vella natively understands
  // But `firstUser` only "emits" when its value changes

  // this will only re-render when firstUser.name changes value.
  return v('p', 'Hello', firstUser.name)

})

If state wasn't already provided as an attribute, every component will receive state as an attr. The default reference will be the state the parent component received.

function MyComponent({ state }){
  // And state can be rebound to a subtree
  // as easily as aliasing it in the attrs
  return v(OtherComponent, { state: state.subsection })
}

state instances will have a few methods for relational queries.

These methods are different from attain queries, where operations focus on the query not the underlying value. But vella doesn't need to be so puritanical because it has a specific use case in mind - so it can optimize the api to feel more like working with native JS structures.

An example of .find

// bad - points to a specified index which is brittle
const user = state.users[0]

// good - a dynamic query which focuses 
// on a specific identity relationship - which is resilient
const user = state.users.find( user => user.id == id() )

And .delete()

const user = state.users.find( user => user.id == id() )

// remove from list/object where user.id == id()
user.delete()

A simple counter example (excuse any typos):

v.boot( document.body, ({ state }) => {
  // initialize the state
  state({
    counters: []
  })

  // mount Counters without passing down state
  return v(Counters)
})

// Counters receives the root state as an attr
// automatically
function Counters({ state }){

  return v('.counters'
    , v('button'
      , 
      // create a counter on click
      { onclick: () => state.counters( xs => xs.concat( { id: uuid(), count: 0 }) )
      }
      , 'Add Counter'
    )
    // map over the counters (.map here produces a Stream<UUID[]> )
    , v.list( () => state.counters.map( x => x.id ),  id =>
      // create a query on the fly for each component
      v(Counter, { state: state.counters.find( x => x.id == id ) })  
    ) 
  )
}

// state is focused on the counter query 
function Counter({ state }){

  return v('counter'
    // because of transform functions 
    // we don't have that awkward count( count() + 1 )
    // and therefore we can take advantage of most utility toolbelts
    , v('button', { onclick: () => state.count( x => x + 1 ), '+' } 
    , v('button', { onclick: () => state.count( x => x - 1 ), '-' } 

    // Only mutates the dom when 
    // `state.users.find( x => x.id == id ).count` changes
    , v('p.count', 'Count', state.count )

    , v( Delete )
  )
}

// receives the counter as a state query
// even that no attr was specifically passed down
function Delete({ state }){
  // Remove the counter from the list
  return v('button', { onclick: () => state.delete(), 'Delete' }
}
CreaturesInUnitards commented 4 years ago

@JAForbes this seems like a tremendously great approach; almost too good to be true! I'd love to have it in an alpha branch sooner than later. I'm curious what the accompanying persistence idioms would/should look like.

JAForbes commented 4 years ago

Yeah seems like there's a bit of support (even @barneycarroll has come around to it 😂). I'll try and find the time to implement a prototype if there's no objections.