rvu

Vue-like React state management

Usage no npm install needed!

<script type="module">
  import rvu from 'https://cdn.skypack.dev/rvu';
</script>

README

rvu

Vue for React

rvu is a state management solution for React.

Example

When the button is clicked, the count display (which lives in a completely separate component) will update automatically. No hooks, props, or context are required.

import { State, View } from 'rvu'

// the state object and the view functions are free to be moved to e.g., different files
const state = State({
  count: 0,
  increment() {
    state.setCount(state.count + 1)
  },
})
const CountDisplay = View(() => (
  <div className="myFancyClassName">{state.count}</div>
))
const IncrementButton = View(() => (
  <button onClick={state.increment}>increment</button>
))
const MyComponent = View(() => (
  <div>
    <CountDisplay />
    <IncrementButton />
  </div>
))

Whenever a State field or subfield updates, all Views that read from that particular field or subfield update (and Views that do not read from that field/subfield are guaranteed not to rerender).

rvu is fundamentally different from most state-management solutions, so it's recommended that you read the entire README before using rvu.

Core API

View

A React function component wrapped in View() will update whenever any State field (or subfield) it references while rendering updates. ("While rendering" means that fields or subfields referenced in effects or callbacks will not trigger updates.)

State

States are objects for storing and updating application state. They work like this:

const state = State({
  users: [] as Array<User>,

  setUserName(userId: string, newName: string) {
    const user = state.users.find(u => u.id === userId)
    if (user) {
      // direct object mutation is allowed in synchronous methods :)
      user.fullName = newName
    } else {
      throw Error('no user with that ID is loaded')
    }
  },

  async fetchUsers(userIds) {
    const users = await fetch(...)

    // Setters like `setUsers` are created automatically for non-function fields.
    // They are also included in the resultant object's TypeScript type the object, so this is completely type-safe.
    // (Executing `state.users = users` would not be allowed here, because this method is not asynchronous)
    state.setUsers(users)
  },
})

export const UserTable = View(() => (
  <div>
    <div>Total users: ${state.users.length}</div>
   <table>
      { state.users.map(user => (
        <tr key={user.id}>
          <td>{user.id}</td>
          <td>{user.fullName}</td>
        </tr>
      ))}
    </table>
    <button onClick={() => state.fetchUsers(state.users.map(u => u.id))}>
      Refresh
    </button>
  </div>
))

State is logged in Redux devtools if it's installed. If you want your state objects to have names in Redux devtools so you can identify them, you can provide a name argument in your state definition, like so:

const state = state('counterState', {
  count: 0,
  increment() {
    state.setCount(state.count + 1)
  },
})
export const counterState = state

Methods

Functions included in state definitions are automatically transformed into Methods. Setter Methods are also automatically generated for any field on a state object that are

Methods have 3 defining features:

  1. They are the ONLY way to modify state
  2. They are ONLY allowed to modify state if they are synchronous
  3. They also provide the functionality of Materialization functions (described below)

Every time you call an Method that updates state, rvu triggers rerenders on all Views that reference any updated fields/subfields.

Methods (like fetchUsers()) are free to read from and modify state on any state.

One important thing to note: Methods may call other Methods, and States will not update until the outermost Method has finished being (synchronously) executed. So: this will cause 2 renders: myObs.setFirstName('Ethan'); myObs.setLastName('Clark');, but this Method will only cause 1 render (even though it calls two other Methods): myObs.setNames(first: string, last: string) { state.setFirstName(first); state.setLastName(last) }

If you want a generic way to execute several Methods together ad-hoc, without having to create higher-level Methods, you could create an Method runner:

const methodRunner = State({
  runAsMethod(cb: (...args: any[]) => any) {
    cb();
  },
});

and use it like so:

methodRunner.runAsMethod(() => {
  someObs.someMethod()
  someObs.someOtherMethod()
})

If you change a function on an state, it will no longer function as a Method after it's been changed, and so it won't be allowed to update state. (If this is a problem for your use case, create an issue and we can assess whether we want to add support for this ability.) A valid use-case for changing a function is if you want to prevent something being transformed into an state (say, a particularly massive object) for performance reasons, but still want Reactions to occur when the reference to that object change. Here's an example:

const state = State('boxExample', {
  giantObjectBox: () => someGiantObject,
  getGiantObject() {
    return this.giantObjectBox()
  },
  setGiantObject(newGiantObject: GiantObjectType) {
    this.giantObjectBox = () => newGiantObject
  },
})

Here, Views and Reactions will update when the giant object is set to a new value, but won't update to changes on subfields of the giant object. Since turning an object into an state has a computational expense, this may be desirable in some cases.

Setter methods

rvu auto-generates setter Methods for you. They are automatically generated for all non-function fields. So, if you define const myObs = View('myViewName', { abc: 123 }), myObs.setAbc will be automatically defined and always-available.

If you define your own setter Method for a field, rvu will respect the Method you define, and will not override it. If for some reason you want to prevent a setter from being generated, define it as null, like so:

const state = State({
  x: 2,
  setX: null,
})

If you define the setter as null, rvu will leave it as such. Doing so will also set the type of setX to null & Function, which means that TypeScript will yell at you if you try to use it, as that value doesn't make sense from a type perspective.

IMPORTANT

You must ONLY pull values out of states from WITHIN Views and Reactions for the Views and Reactions to update when the states update.

So this will work:

const clickCounts = State('myObs', {
  clicks: 0
})
const ClickCounter = View(() => (
  <div onClick={() => myObs.setClicks(myObs.clicks + 1)}>{myObs.clicks}</div>
))

And this will not work:

const clickCounts = State('myObs', {
  clicks: 0
})
const { clicks, setClicks } = clickCounts
const ClickCounter = View(() => (
  <div onClick={() => setClicks(clicks + 1)}>{clicks}</div>
))

For a big breakdown of this idea, see here

Materialization

Methods function as Materialization functions when used as such. Materialization functions cache Materialization state, allowing you to avoid expensive recalculations. They work like this:

const state = State('userState', {
  users: [] as Array<User>,

  // rvu stores the result of this after it's called once,
  // and only ever recalculates it if `state.users` changes,
  // which makes it very efficient
  activeUsers() {
    return state.users.filter(u => !u.deactivated)
  },
  // the result of calls to this method will be cached by `id`, automatically,
  // updating the same as the above case
  user(id: string) {
    return state.users.find(u => u.id === id) || null
  }
})
export const userState = state

Materialization function results behave the same as state state fields, so this component will always display the user's latest field values, even after those values change:

// the logic inside the definition passed to `Materialization` above will only execute once in the rendering of this,
// and will only execute once when either `userId` changes or that user's `fullName` or `id` changes.
const User = View(() => (<div>User ${userState.user(userId).fullName} (id: ${userState.user(userId).id})</div>))

Materialization functions are free to reference both obervable state and other Materialization function state. So activeUser in activeUserState is a valid Materialization function:

const userFullName = Materialization((id: string | number | whatever) => user(id)?.fullName)

const userState = State('userState', {
  users: [] as Array<User>,
})
const activeUsersState = State('activeUsersState', {
  activeUsers() {
    return userState.users.filter(u => !u.deactivated)
  },
})
const activeUserState = State('activeUserState', {
  // the result of calls to this method will be cached by `id`, automatically,
  // updating the same as the above case
  activeUser(id: string) {
    return activeUsersState.users.find(u => u.id === id) || null
  }
})

IMPORTANT

Do not use try/catch within a Materialization function. Errors here can break Views and Reactions. (Due to the nature of JavaScript, there's no way to keep stack traces sane while still allowing some Reactions to work while others have broken.)

For this reason, TypeScript's "strict" mode is deeply encouraged.

IMPORTANT

The same rule about state state holds with Materialization state: you must ONLY call Materialization functions from WITHIN Views and Reactions for the Views and Reactions to update when Materialization state updates.

Special use-case API

These exports allow for interoperability with other frameworks.

Reaction

API: Reaction(def: () => (void | (nonReactiveFollowup: () => void))): function stop(): void

If you want to "push" values from an View into something else as they update, you can use Reaction to do so.

Every time any value referenced in a Reaction updates, Reaction will rerun.

Your Reaction definition may return a function, if you wish. This function will be called immediatley after the Reaction completes, and any state values referenced by this function will not trigger Reaction re-runs when they change.

Creating a Reaction returns a stop() function, which can be called to stop the Reaction from running.

overrideMobxConfig

rvu objects are valid mobx observables. If you want to configure mobx, you can do so via overrideMobxConfig, which accepts the same arguments as mobx's configure export. The vast majority of users should NOT require this functionality.

Logging

rvu logs everything in Redux DevTools, if available.

Comparison with MobX

rvu condenses all of the power of MobX's massive API (> 100 exports) into a tiny, opinionated API (2 "core" exports + 2 "special use-case" exports). It requires no prior knowledge of MobX. That being said, if you do want to use it with Mobx, rvu State objets are valid Mobx observables, as mentioned before.

gotchas

dereferencing State or Materialization fields outside of Views or Reactions

This is mentioned above, but worth repeating: if you pull fields off of an state outside of an View or Reaction, and then use those fields inside an View or Reaction, the View/Reaction will not update when those fields change on the state. You should only dereference fields you want to "listen" to inside of Views or Reactions.