better-mobx

typesafe react state hook / mobx alternative

Usage no npm install needed!

<script type="module">
  import betterMobx from 'https://cdn.skypack.dev/better-mobx';
</script>

README

better-mobx

better-mobx is a state-management solution for TypeScript + React. It allows you to define observable state, which can be referenced from any React function component wrapped in observable. Whenever any observable field or subfield updates, only those observable components that read from that particular field or subfield update.

better-mobx is a fundamentally different programming model than most state-management solutions, so it's recommended that you read the entire README before using better-mobx.

Logging

better-mobx logs everything in Redux DevTools, if available.

Comparison with MobX

better-mobx condenses all of the power of MobX's massive API (> 100 exports) into a tiny, opinionated API (3 "core" exports + 2 "special use-case" exports). It requires no prior knowledge of MobX.

Core API

observer

A React function component wrapped in observer() will update whenever any observable field (or subfield) it references updates.

observable

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

// the first argument is the name of this state as it will appear in Redux devtools, if you're using them
const state = observable('userState', {

  users: [] as Array<User>,

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

    // Setters like `setUsers` are created automatically for non-function fields.
    // State must be modified via synchronous methods; since `await` was called above, the
    // method is no longer running, so another method (`setUsers`) must be called
    state.setUsers(users)
  },

  // this is an `method`
  setUserName(userId: string | number | whatever, newName: string) {
    const user = state.users.find(u => u.id === userId)
    if (user) {
      user.fullName = newName // mutate the object directly
    } else {
      throw Error('no user with that ID is loaded')
    }
  },

})

export const UserTable = observer(() => (
  <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>
))

Methods

Functions included in observable definitions are automatically transformed into methods.

Methods have 3 defining features:

  1. They are the ONLY way to modify state
  2. Their state modifications MUST be synchronous. (It's fine if they trigger an asynchronous process, but they may not update state after awaiting anything or in a callback.)
  3. They also provide the functionality of derived functions (described below)

Every time you call an method that updates state, better-mobx triggers rerenders on all observers that reference any updated fields/subfields.

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

One important thing to note: methods may call other methods, and observables 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 = observable("methodRunner", {
  runAsMethod(cb: (...args: any[]) => any) {
    cb();
  },
});

and use it like so:

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

If you change a function on an observable, 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 observable (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 = observable('boxExample', {
  giantObjectBox: () => someGiantObject,
  getGiantObject() {
    return this.giantObjectBox()
  },
  setGiantObject(newGiantObject: GiantObjectType) {
    this.giantObjectBox = () => newGiantObject
  },
})

Here, observers 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 observable has a computational expense, this may be desirable in some cases.

Setters

Setters are methods that better-mobx auto-generates for you. They are automatically generated for all non-function fields. So, if you define const myObs = observer('myObserverName', { abc: 123 }), myObs.setAbc will be automatically defined and always-available.

If you define your own setter methods, better-mobx 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 = observable({
  x: 2,
  setX: null,
})

If you define the setter as null, better-mobx 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 observables from WITHIN observers and reactions for the observers and reactions to update when the observables update.

So this will work:

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

And this will not work:

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

For a big breakdown of this idea, see here

derived

derived functions cache derived state, allowing you to avoid expensive recalculations. They work like this:

// better-mobx stores the result of this after it's called once,
// and only ever recalculates it if `state.users` changes,
// which makes it very efficient
const activeUsers = derived(() => {
  // (`state` is an observable object)
  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
const user = derived((id: string | number | whatever) => {
  // (`state` is an observable object)
  return state.users.find(u => u.id === id) || null
})

derived function results behave the same as observable 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 `derived` 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 = observer(() => (<div>User ${user(userId).fullName} (id: ${user(userId).id})</div>))

derived functions are free to reference both obervable state and other derived state. So this is a valid derived function:

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

As mentioned above, all fields on observers also function as derived functions. So in the following example, userState.fullName provides identical functionality to userFullName above:

const userState = observer('userState', {
  fullName(id: string | number | whatever) {
    return user(id)?.fullName
  }
})

IMPORTANT

Do not use try/catch within a derived function. Errors here can break observers 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, deeply encouraged.

IMPORTANT

The same rule about observable state holds with derived state: you must ONLY call derived functions from WITHIN observers and reactions for the observers and reactions to update when derived state updates.

Special use-case API

These exports allow for interoperability with other frameworks.

reacttion

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

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

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

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

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

mobx

While it is not recommended, if you wish to use better-mobx's version of mobx directly, you may via import { mobx } from 'better-mobx'

gotchas

dereferencing observable or derived fields outside of observers or reacttions

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