0.52.1 • Published 12 days ago

@expressive/mvc v0.52.1

Weekly downloads
-
License
MIT
Repository
github
Last release
12 days ago

Classes which extend Model can manage behavior for components. Custom models are easy to define, use and even provide. While hooks are great, state and logic are not a part of well-formed JSX. Models help wrap that stuff in robust, portable controllers instead.

How to use

Step 1

Create a class to extend Model and shape it to your liking.

class Counter extends Model {
  current = 1

  increment = () => { this.current++ };
  decrement = () => { this.current-- };
}

Step 2

Pick a built-in hook, such as use(), to make a component stateful.

const KitchenCounter = () => {
  const { current, increment, decrement } = Counter.use();

  return (
    <div>
      <button onClick={decrement}>{"-"}</button>
      <pre>{current}</pre>
      <button onClick={increment}>{"+"}</button>
    </div>
  )
}

Step 3

See it in action 🚀 You've already got something usable!

Track any number of values

Updating any of them will trigger a render.

class State extends Model {
  a = 1;
  b = 2;
  c = 3;
}

const MyComponent = () => {
  const { a, b, c } = State.use();

  return <div>...</div>
}

Refresh using simple assignment

Reserved set loops back to the instance, to update from inside a component.

class Hello extends Model {
  name = "World";
}

const MyComponent = () => {
  const { name, set } = Hello.use();

  return (
    <p onClick={() => set.name = "Foobar"}>
      Hello {name}!
    </p>
  )
}

Share state via context

Provide and consume with simple methods. Classes are their own key.

import Model, { Provider } from "@expressive/mvc";

class Shared extends Model {
  value = 1;
}

const Parent = () => (
  <Provider of={Shared}>
    <Child />
  </Provider>
)

const Child = () => {
  const { value } = Shared.get();

  return (
    <p>Shared value is: {value}!</p>
  )
}

Respond to lifecycle

Hooks aware of lifecycle will call method-handlers you define.

class Timer extends Model {
  interval = undefined;
  elapsed = 1;

  componentDidMount(){
    const inc = () => this.elapsed++;
    this.interval = setInterval(inc, 1000);
  }

  componentWillUnmount(){
    clearInterval(this.interval);
  }
}

const MyTimer = () => {
  const { elapsed } = Timer.use();

  return <p>I've existed for { elapsed } seconds!</p>;
}

Control components with async

class Greetings extends Model {
  response = undefined;
  waiting = false;
  error = false;

  sayHello = async () => {
    this.waiting = true;

    try {
      const res = await fetch("https://my.api/hello");
      this.response = await res.text();
    }
    catch(){
      this.error = true;
    }
  }
}

const MyComponent = () => {
  const { error, response, waiting, sayHello } = Greetings.use();

  if(response)
    return <p>Server said: {response}</p>

  if(error)
    return <p>There was an issue saying hello</p>

  if(waiting)
    return <p>Sent! Waiting on response...</p>

  return (
    <a onClick={sayHello}>Say hello to server!</a>
  )
}

Extend to configure

Use class extension to set needed properties. Reusable logic is easy to implement, document and share.

abstract class About extends Model {
  abstract name: string;
  abstract birthday: Date;
  abstract wants: any;

  happyBirthday(){
    // ...
  }
}

class AboutMe extends About {
  name = "John Doe";
  birthday = new Date("January 1");
  wants = "a PS5";
}

And you can do more, all with type safety and code-assist, out of the box.

Contents

Introductions

  Install and Import

Getting Started   The basics   Destructuring   Methods   Getters   Constructor   Applied Props

Dynamics   Lifecycle   Events   Listening   Dispatch   Built-in   Monitored Props   Async and callbacks

Shared State   Basics   Provider   Spawning   With props   Multiple   Globals   Setup

Consuming   Hooks   get   tap   Consumer   children   get   tap   has

Composition   Simple Composition   Ambient Controllers   Child Controllers   Parent Controller

Best Practices   Documenting and Types

Concepts

  Subscriptions   Auto Debounce

Install with your preferred package manager

npm install --save @expressive/mvc

Import and use in your react apps

import Model from "@expressive/mvc";

Ultimately, the workflow is simple.

  1. Create a class. Fill it with the values, getters, and methods you'll need.
  2. Extend Model (or any derivative, for that matter), making it reactive.
  3. Within a component, use built-in methods as you would normal hooks.
  4. Destructure out the values used by a component, to then subscribe.
  5. Update those values on demand. Component will sync automagically. ✨

You may recognize this as similar to approach pioneered by MobX. Here though, we avoid using decorators and other boilerplate.

Glossary

  • Model: Any class you'll write extending Model. Defines a type of state.
  • Controller: An instance of Model, containing its state and behavior.
  • State: The values managed by a controller, as defined by the model.
  • Subscriber: A hook, or otherwise a callback, responding to updates.
  • View: A function-component which accepts hooks, to contain a controller.
  • Element: Instance of a component mounted, having state and lifecycle.

Start with a bare minimum:

import Model from "@expressive/mvc";

class Counter extends Model {
  number = 1
}

Define a model with any number of properties needed. Values set in constructor (or class properties) are the initial state.

import { Plus, Minus } from "./components";

const KitchenCounter = () => {
  const state = Counter.use();

  return (
    <div>
      <Minus onClick={() => state.number -= 1} />
      <pre>{ state.number }</pre>
      <Plus onClick={() => state.number += 1} />
    </div>
  )
}

One of the static-methods on your class will be use. This is a hook; it will create a new instance of your model and attach it to a component.

Now, we can edit instance properties as we see fit. When values change, our hook calls for a refresh as needed.

Model hooks are lazy, and will only update for values seen to be in-use.

A good idea is to destructure values. This keeps intent clear, and avoids unexpected behavior.

After destructuring though, we still might need access to the full state. For this, a get and set property are defined as a loop-back.

set

Mainly for assigning new values to state.

const KitchenCounter = () => {
  const { number, set } = Counter.use();

  return (
    <div>
      <Minus onClick={() => set.number -= 1} />
      <pre>{ number }</pre>
      <Plus onClick={() => set.number += 1} />
    </div>
  )
}

set.number See what we did there? 🤔

get

When an observable value is accessed, the controller assumes a refresh is needed anytime that property changes. In plenty of situations, this isn't the case, so get serves as an escape-hatch.

const { get: state, value } = Model.use();

Is a nicer (and more stable) way of saying:

const state = Model.use();
const { value } = state;

Good to know: Reason this works is because returned state is a proxy, there to spy on property access. On the other hand, get and set are a direct reference to state.

What's a class without some methods? Defining "actions" can help delineate state-change.

class Counter extends Model {
  current = 1

  // Using arrow functions, to preserve `this`.
  // In-practice, this acts a lot like a useCallback, but better!
  increment = () => { this.current++ };
  decrement = () => { this.current-- };
}

With this, you can write even the most complex components, all while maintaining key benefits of a functional-component. Much easier on the eyeballs.

const KitchenCounter = () => {
  const { current, decrement, increment } = Counter.use();

  return (
    <Row>
      <Minus onClick={decrement} />
      <pre>{ current }</pre>
      <Plus onClick={increment} />
    </Row>
  )
}

View in CodeSandbox

Good to know: While obviously cleaner, this is also more efficient than inline-functions. Not only do actions have names, we avoid a new closure for every render. This can save a lot of unnecessary rendering downstream, for assumed "new" callbacks. 😬

State may contain derivative values too, simply define get-methods on your model. They will be managed by the controller automatically.

Through the same mechanism as hooks, getters know when the specific properties they access are updated. Whenever that happens, they rerun. If a new value is returned, the update is paid forward.

const { floor } = Math;

class Timer extends Model {
  constructor(){
    super();
    setInterval(() => this.seconds++, 1000);
  }

  seconds = 0;

  get minutes(){
    return floor(this.seconds / 60);
  }

  get hours(){
    // getters can subscribe to other getters as well 🤙
    return floor(this.minutes / 60);
  }

  get format(){
    const hrs = this.hours;
    const min = this.minutes % 60;
    const sec = this.seconds % 60;

    return `${hrs}:${min}:${sec}`;
  }
}

View in CodeSandbox

A getter's value is cached, facing the user. It will only run when a dependency changes, and not upon access as you might expect. However, getters are also lazy, so first access is one exception.

Getters run whenever a controller thinks they could change. Make sure to design them along these lines:

  • Getters should be deterministic. Only expect a change where inputs have changed.
  • Avoid computing from values which change a lot, but don't change output as often.
  • Goes without saying but, side-effects are an anti-pattern and may cause infinite loops.

The method use(...), while building an instance, will pass its arguments to the constructor. This helps in reusing models, being able to customize the initial state.

Typescript

class Greetings extends Model {
  firstName: string;
  surname: string;

constructor(fullName: string){ super();

const [ first, last ] = fullName.split(" ");

this.firstName = first;
this.surname = last;

} }

```jsx
const SayHello = ({ fullName }) => {
  const { firstName } = Greetings.use(fullName);

  return <b>Hello {firstName}!</b>;
}

Besides use, similar methods will assign values after a controller is created. Good alternative to a manual setup.

class Greetings extends Model {
  name = undefined;
  birthday = undefined;

  get firstName(){
    return this.name.split(" ")[0];
  }

  get isBirthday(){
    const td = new Date();
    const bd = new Date(this.birthday);

    return (
      td.getMonth() === bd.getMonth() &&
      td.getDate() === bd.getDate()
    )
  }
}

Greetings defines firstName & isBirthday; however they themselves depend on name and birthday, which start out undefined. We can pass in props to help with that.

const HappyBirthday = (props) => {
  const { firstName, isBirthday } = Greetings.uses(props);

  return (
    <div>
      <span>Hi {firstName}</span>
      {isBirthday
        ? <b>, happy birthday!</b>
        : <i>, happy unbirthday! {"🎩"}</i>
      }
    </div>
  );
};

Now, we can define these props from outside!

const SayHello = () => (
  <HappyBirthday
    name="John Doe"
    birthday="January 1"
  />
)

View in CodeSandbox

This method is naturally picky and will only capture values pre-defined, as undefined or otherwise. You can however, specify which properties to pull from, like so:

const state = Model.uses({ ... }, ["name", "birthday"]);

This way, objects containing more than expect data may be used, without collisions or pollution of state.

✅ Level 1 Clear!

In this chapter we've learned the basics, of how to create and utilize a custom state. For most people who just want smarter components, this could be enough! However, we can go well beyond making just a fancy hook.

So far, all our examples have been passive. Models can serve a bigger roll, however, to evolve state even without user input.

Because state is a portable object, we can modify it from anywhere, and more-crucially whenever. This makes async things pretty low maintenance. You implement the business-logic, controllers will handle the rest.

Here are a few ways to smarten up your controllers:

Built-in hooks, besides receiving updates, can dispatch events related the component they're a part of. On the model, reserved methods may be defined to handle these events.

class TimerControl extends Model {
  interval = undefined;
  elapsed = 1;

  componentDidMount(){
    const inc = () => this.elapsed++;
    this.interval = setInterval(inc, 1000);
  }

  componentWillUnmount(){
    clearInterval(this.interval);
  }
}

Where have I seen this before?... 🤔

const MyTimer = () => {
  const { elapsed } = TimerControl.use();

return I've existed for { elapsed } seconds!; }

<sup><a href="https://codesandbox.io/s/example-component-lifecycle-8xx5c">View in CodeSandbox</a></sup>

> We will [learn more](#lifecycle-api) about Lifecycle events in a later section.

<br />

<h2 id="concept-events">Event Handling</h2>

Besides state change, what a subscriber really cares about is events. *Updates are just one source for an event.* When any property gains a new value, subscribers are simply notified and act accordingly.

> **Good to know:** <br/>
> All changes to state occure in batches, called frames. Whenever state receives a new value, a zero-second timer starts. Anything which updates before the commit-phase is considered "reasonably syncronous" and included. After that, events are then fired, and  controller resets for the next event.

<br />

<h2 id="concept-listen-event">Listening for events manually</h2>

```js
const normalCallback = (value, key) => {
  console.log(`${key} was updated with ${value}!`)
}

const squashCallback = (keys) => {
  console.log(`Update did occure for: ${keys}!`)
}

By default, callbacks are run once for every key/value update, even if multiple happen at the same time. If you'd rather know when an event happens for any key, squash will fire once for a frame, with a list of keys updated.

state.on(key, callback, squash?, once?) => remove

Register a new listener for given key(s). callback will be fired when state[key] updates, or a synthetic event is sent.

The method also returns a callback; use it to stop subscribing.

state.once(key, callback, squash?) => cancel

Will also expire when triggered. May be cancelled with the returned callback.

state.once(key) => Promise<keys[]>

If callback is not provided, once will return a Promise of keys watched/updated.

function effectCallback(state){
  this === state; // true

  console.log(`Update did occure in one or more watched values.`)

  return () =>
    console.log(`Another update will occure, cleanup may be done here.`)
}

state.effect(effectCallback, keys) => remove

A more versatile method used to monitor one or more properties with the same callback. Optionally, that callback may return a handler to clean-up, when the process repeats.

function subscribedCallback(state){
  const { name } = this; // or `state`

  console.log(`Hello ${name}`)

  return () =>
    console.log(`Goodbye ${name}`)
}

state.effect(subscribedCallback) => remove

If no explicit keys are given, the effect callback will self-subscribe. Just like a hook, it detects values used and automatically will update for new ones.

Note: In order to scan for property access, this effect will run at-least once, immediately.

state.update(key)

Fires a synthetic event, sent to all listeners for key(s) selected, be them components or callback-listeners above. Any string or symbol may be a key. Naturally, a key which is defined on state already, will be treated as an update.

Events make it easier to design around closures and callbacks, keeping as few things on your model as possible. Event methods may also be used externally, for other code to interact with.

Controllers will emit lifecycle events for a bound component (depending on the hook).

Often lifecycle is critical to a controller's correct behavior. While we do have lifecycle-methods, it's recommended to use events where able. This way, if your class is extended and redefines a handler, yours is not at the mercy of a super[event]() call.

Lifecycle events share names with their respective methods, listed here.

Event handling in-practice:

class TickTockClock extends Model {
  constructor(){
    super();

    // begin timer after component mounts
    this.once("componentDidMount", this.start);
  }

  seconds = 0;

  get minutes(){
    return Math.floor(this.seconds / 60);
  }

  tickTock(seconds){
    if(seconds % 2 == 1)
      console.log("tick")
    else
      console.log("tock")
  }

  logMinutes(minutes){
    if(minutes)
      console.log(`${minutes} minutes have gone by!`)
  }

  start(){
    const self = setInterval(() => this.seconds++, 1000);
    const stop = () => clearInterval(self);

    // run callback every time 'seconds' changes
    this.on("seconds", this.tickTock);

    // run callback when 'minutes' returns a new value
    this.on("minutes", this.logMinutes);

    // run callback when component unmounts
    this.once("componentWillUnmount", stop);
  }
}

We saved not only two methods, but kept interval in scope, rather than a property. Pretty clean!

Sometimes, we want to react to changes coming from outside, usually via props. Observing a value requires that you integrate it, however after that, consumption is easy.

Roughly equivalent to uses()

This method helps watch an object by running assign on it every render. Because controllers only react to new values, this makes for a simple way to watch externals. Combine this with getters and event-listeners, to do things when inputs change.

class ActivityTracker extends Model {
  active = undefined;

  get status(){
    return this.active ? "active" : "inactive";
  }

  constructor(){
    super();

    this.on("active", (yes) => {
      if(yes)
        alert("Tracker just became active!")
    })
  }
}
const DetectActivity = (props) => {
  const { status } = ActivityTracker.using(props);

  return (
    <big>
      This element is currently {status}.
    </big>
  );
}
const Activate = () => {
  const [active, setActive] = useState(false);

  return (
    <div onClick={() => setActive(!active)}>
      <DetectActivity active={active} />
    </div>
  )
}

View in CodeSandbox

Note: Method is also picky (ala uses), and will ignore values which don't already exist in state.

Because dispatch is taken care of, we can focus on just editing values as we need to. This makes the async stuff like timeouts, promises, callbacks, and even Ajax a piece of cake.

class StickySituation extends Model {
  agent = "Bond";
  remaining = 60;

  constructor(){
    super();
    this.once("componentWillMount", this.missionStart);
  }

  missionStart(){
    const timer = setInterval(this.tickTock, 1000);

    // select may be any string, symbol, or collection of them
    this.once(["componentWillUnmount", "oh no!"], () => {
      clearInterval(timer);
    });
  }

  tickTock = () => {
    const timeLeft = this.remaining -= 1;

    if(timeLeft === 0)
      this.update("oh no!");
  }

  getSomebodyElse = async () => {
    const res = await fetch("https://randomuser.me/api/");
    const data = await res.json();
    const recruit = data.results[0];

    this.agent = recruit.name.last;
  }
}
const ActionSequence = () => {
  const {
    agent,
    getSomebodyElse,
    remaining
  } = StickySituation.use();

  if(remaining === 0)
    return <h1>🙀💥</h1>

  return (
    <div>
      <div>
        <b>Agent {agent}</b>, we need you to diffuse the bomb!
      </div>
      <div>
        If you can't do it in {remaining} seconds, 
        Schrödinger's cat may or may not die!
      </div>
      <div>
        But there's still time! 
        <u onClick={getSomebodyElse}>Tap another agent</u> 
        if you think they can do it.
      </div>
    </div>
  )
}

View in CodeSandbox

Notice how our components remain completely independent from the logic.

If we want to modify or even duplicate our ActionSequence, with a new aesthetic or different verbiage, we don't need to copy or edit any of these behaviors!

👾 Level 2 Clear!

Nice, now we're able to create full-blown experiences, while keeping the number of hooks used to a minimum (namely one). That's good, because being able to separate this stuff promotes better patterns. Reduce, reuse, recycle.

However we can keep going, because with Models, sharing logic means more than just reusing logic.

One of the most important features of a Model is the ability to share a single, active state with any number of users, be them components or even other controllers. Whether you need state in-context or to be usable app-wide, you can, with a number of simple abstractions.

Before going in depth, a quick example will help get the basic point across.

Step 1

Start with a normal, run-of-the-mill Model.

export class FooBar extends Model {
  foo = 0;
  bar = 0;
};

Step 2

Import the creatively-named Provider component, and pass an instance of state to its of prop.

import { Provider } from "@expressive/mvc";

const Example = () => {
  const foobar = FooBar.use();

  return (
    <Provider of={foobar}>
      <Foo />
      <Bar />
    </Provider>
  )
}

Step 3

Where FooBar.use() creates an instance of FooBar, FooBar.get() fetches an existing instance of FooBar.

const Foo = () => {
  const { foo } = FooBar.get();

  return <p>The value of foo is {foo}!</p>
}

const Bar = () => {
  const { bar } = FooBar.get();

  return <p>The value of bar is {bar}!</p>
}

Using the class itself, we have a convenient way to "select" what type of state we want, assuming it's in context.

There's another big benefit here: types are preserved.

Models, when properly documented, maintain autocomplete, hover docs, and intellisense, even as you pass controllers all-throughout your app! 🎉

Let's first create and cast a state, for use by its components and peers. In the next chapter, we'll expand on how to consume them.

By default, Model uses React Context to find instances from upstream.

There are several ways to provide a controller. Nothing special on the model is required.

export class FooBar extends Model {
  foo = 0;
  bar = 0;
};

Note the export here.

Models and their consumers do not need to live in same file, let alone module. Want to interact with another library's state? This can make for a pro-consumer solution. ☝️😑

Here, pass a controller to the of prop. For any children, it will be made accessible via its class.

Unlike a normal Context.Provider, a Provider is generic and good for any (or many) different states.

import { Provider } from "@expressive/mvc";
import { FooBar } from "./controller";

export const App = () => {
  const foobar = FooBar.use();

  return (
    <Provider of={foobar}>
      <Foo/>
      <Bar/>
    </Provider>
  )
}

Without constructor-arguments, creating the instance separately can be an unnecessary step. Pass a Model itself, to both create and provide a state in one sitting.

export const App = () => {
  return (
    <Provider of={FooBar}>
      <Foo/>
      <Bar/>
    </Provider>
  )
}

When a Model is passed to of directly, the Provider will behave similarly to Model.using(). All other props are forwarded to state and likewise observed.

const FancyFooBar = (props) => {
  return (
    <Provider of={FooBar} foo={props.foo} bar={10}>
      {props.children}
    </Provider>
  )
}

Now, consumers can see incoming-data, which only the parent has access to!

Finally, of can accept a collection of models and/or state objects. Mix and match as needed to ensure a DRY, readible root.

const MockFooBar = () => {
  const hello = Hello.use("world");

  return (
    <Provider of={{ hello, Foo, Bar }}>
      {props.children}
    </Provider>
  )
}

While Providers are nice for contextual-isolation, sometimes we want just one controller for a purpose in an entire app. Think of concepts like login, routes, or interaction with an API.

For instance, if ever used react-router, you'll know <BrowserRouter> is only needed for its Provider. You never have more than one at a time, so context is somewhat moot.

For this reason we have Singleton, a type of state to exist everywhere. Hooks will present the same as their Model counterparts, but under the hood, always retrieve a single, promoted instance.

Defining a Singleton

Simply extend Singleton, itself a type of Model.

import { Singleton } from "@expressive/mvc";
import { getCookies } from "./monster";

class Login extends Singleton {
  loggedIn = false;
  userName = undefined;

  componentDidMount(){
    this.resumeSession();
  }

  async resumeSession(){
    const { user } = await getCookies();

    if(user){
      loggedIn = true;
      userName = user.name;
    }
  }
}

Assume this class for examples below.

Singletons will not be useable, until they're initialized in one of three ways:

Method 1:

Singleton.create(...)

A method on all Models, create on a Singleton will also promote that new instance. This can be done anytime, as long as it's before a dependant (component or peer) tries to pull data from it.

window.addEventListener("load", () => {
  const loginController = Login.create();

  loginController.resumeSession();

  ReactDOM.render(<App />, document.getElementById("root"));
});

Method 2:

Singleton.use()

Create an instance with the standard use-methods.

const LoginGate = () => {
  const { get: login, loggedIn, userName } = Login.use();

  return loggedIn
    ? <Welcome name={userName} />
    : <Prompt onClick={() => login.resumeSession()} />
}

An instance of Login is available immediately after use() returns. However, it will also become unavailable if LoginPrompt does unmount. If it mounts again, any new subscribers get the latest version.

Method 3:

<Provider of={Singleton}>

export const App = () => {
  return (
    <>
      <Provider of={Login} />
      <UserInterface />
    </>
  )
}

This will have no bearing on context, it will simply be "provided" to everyone. Wrapping children, in this case, is doable but optional. The active instance is likewise destroyed when its Provider unmounts.

You will most likely use this to limit the existence of a Singleton (and its side-effects), to when a particular UI is on-screen.

Whether a state extends Model or Singleton won't matter; they both present the same.

export class FooBar extends Model {
  foo = 0;
  bar = 0;
};

Models make fetching a state super easy. On every model, there are defined three useContext like methods. They find, return and maintain the nearest instance of class they belong to.

The most straight-forward, finds the nearest instanceof this within context. If given a key it will 'drill', either returning a value (if found) or undefined. Will not respond to updates.

Will subscribe to values on fetched instance, much like use will.

Children sharing a mutual state, thusly can affect eachother's state.

const InnerFoo = () => {
  const { bar, set } = FooBar.tap();

  return (
    <>
      <pre onClick={() => set.foo++}>Foo</pre>
      <small>(Bar was clicked {bar} times!)</small>
    </>
  )
}
const InnerBar = () => {
  const { foo, set } = FooBar.tap();

  return (
    <>
      <pre onClick={() => set.bar++}>Bar</pre> 
      <small>(Foo was clicked {foo} times!)</small>
    </>
  )
}

View in CodeSandbox

With the expect flag, tap will throw if chosen property is undefined at the time of use. This is useful because output is cast as non-nullable. This way, values may be destructured without assertions (or complaints from your linter).

Covered in a later section, tag will also report lifecycle-events to its parent controller. An id argument is used to setup and identify the source of such updates.

Hooks are great, but not all components are functions. For those we have the Consumer component. This is also generic, and able to capture state for any Model passed to its of prop.

There are a number of options, to get info with or without a typical subscription.

When a function is passed as a child, returned elements will replace the Consumer. The function recieves its state as first argument, and subscribed updates will trigger new renders.

Note: If you spread in props directly, all possible values will be subscribed to.

const CustomConsumer = () => {
  return (
    <Consumer of={ModernController}>
      ({ value }) => <OldSchoolComponent value={value} />
    </Consumer>
  )
}

Consumers allow state to drive classical components easily, assuming the values on that state match up with expected props.

If rendered output isn't needed, you can define a handler on the tap prop. This is better for side-effects, more specific to a UI than a controller itself.

Also allows for async, since return value (a promise) can be ignored.

const ActiveConsumer = () => {
  return (
    <Consumer of={Timer} tap={({ minutes, seconds }) => {
      console.log(`Timer has been running for ${minutes}m ${seconds}s!`)
    }}/>
  )
}

Callback runs per-render of the parent component, rather than per-update.

const LazyConsumer = () => {
  return (
    <Consumer of={Timer} get={({ minutes, seconds }) => {
      console.log(`Timer is currently at ${minutes}m ${seconds}s!`)
    }}/>
  )
}

Use <Consumer /> to intregrate traditional components, almost as easily as modern function-components!

While context is great for components, controllers can use it too, that of the component where used. This is helped by importing a special function, likewise called tap.

This is an instruction, in-short a factory function. It tells the controller while initializing: the defined property is special, run some logic to set that up.

On your Model, assign a property with the value of tap(Model), passing in the type of state you're interested in.

As a new controller spawns, it will try and fetch the requested instance. If found, the peer will be accessible to methods, accessors, etc. via that property.

tap(Type, required?)

import Model, { tap } from "@expressive/mvc";

class Hello extends Model {
  to = "World";
}

class Greet extends Model {
  hello = tap(Hello);

  get greeting(){
    return `Hello ${this.hello.to}`;
  }
}

Create a controller which taps another one. Make sure it's wrapped by proper Provider, and the controller will fetch that instance from context.

import { Provider } from "@expressive/mvc";

const App = () => {
  return (
    <Provider of={Hello}>
      <SayHi />
    </Provider>
  )
}

const SayHi = () => {
  const { greeting } = Greet.tap()

  return (
    <p>{greeting}!</p>
  )
}

Here SayHi, without direct access to the Hello controller, can still get what it needs. The actionable value greeting comes from Greet, which itself gets data from Hello. This is an example of scope-control, allowing code to written as needs-based. Generally speaking, this often makes a program more robust and maintainable.

🎮 Level 3 Complete!

Here we've learned how to make state accessible, in more ways than one. Now with these tools we're able to tackle more advanced connectivity. Next we'll get into ecosystems and how to split behavior into simple, often-reusable chunks and hook (pun intended) them together.

Ultimately the purpose of Models, and be that classes in-general, is to compartmentalize logic. This makes code clear and easy to duplicate. Ideally, we want controllers to be really good at one thing, and to cooperate with other systems as complexity grows.

This is how we'll build better performing, easier to work on applications.

In broad strokes, here are some of the ways to set mutliple states up, to work together:

Nothing prevents the use of more than one state per component. Take advantage of this to combine smaller states, rather than make big, monolithic state.

  class Foo extends Model {
    value = 1
  }
  
  class Bar extends Model {
    value = 2
  }

  const FibonacciApp = () => {
    const foo = Foo.use();
    const bar = Bar.use();

    return (
      <div>
        <div onClick={() => { foo.value += bar.value }}>
          Foo's value is {foo.value}, click to add bar!
        </div>
        <div onClick={() => { bar.value += foo.value }}>
          Bar's value is {bar.value}, click to add foo!
        </div>
      </div>
    )
  }

View in CodeSandbox

While the above works fine, what if we want something more organized and reusable? Fortunately, we can "wrap" these models into another, and use that one instead. Think of it like building a custom hook out of smaller (or the core React) hooks.

use(Type, callback?)

Import another instruction, use and pass it a Model to attach a child-instance upon creation.

import Model, { use } from "@expressive/mvc";

class FooBar extends Model {
  foo = use(Foo);
  bar = use(Bar);

  get value(){
    return this.foo.value + this.bar.value;
  }

  addFoo = () => this.bar.value = this.value;
  addBar = () => this.foo.value = this.value;
}

Now, we have a good way to mixin controllers. More importantly though, we have a place to put mutually-inclusive things, before which had to live with JSX. This brings us back to dumb-component paradise. 🏝

const FibonacciApp = () => {
  const { foo, bar, value, addFoo, addBar } = FooBar.use();

  return (
    <div>
      <div>
        Foo + Bar = {value}
      </div>
      <div onClick={addBar}>
        Foo's value is ${foo.value}, click to add in bar!
      </div>
      <div onClick={addFoo}>
        Bar's value is ${bar.value}, click to add in foo!
      </div>
    </div>
  )
}

View in CodeSandbox

Subscriber will not only subscribe to FooBar updates, but that of its children as well! This lets us keep complexity out of components, and add features without adding techincal debt.

While separating concerns, there will be times where designing a Model expressly to help another makes sense, and a reference to that parent is desirable. For this we have the parent instruction.

parent(Type, required?)

Returns a reference to the controller which this is a child. Optional parameter, required will throw if child model is created outside of that parent, otherwise property may be undefined.

import Model, { use, parent } from "@expressive/mvc";

class Control extends Model {
  child = use(Dependant);
}

class Dependant extends Model {
  parent = parent(Control, true);
}

🧱 Level 4 Complete!

Here we've learned how Models allow controllers to cooperate and play off of eachother. They allow us to break-up behavior into smaller pieces, building-blocks if you will. With that, we have an easy way to separate concerns, freeing up focus for more added nuance and features.

:construction: More Docs are on the way! 🏗

Typescript is your friend and Expressive is built laregely to facilitate its use within React apps.

A ton of focus has been put in to make sure all features of Expressive are in-line with what Typescript lanaguage supports. Transparency is key for a great developer experience, and MVC's are designed to infer often and fail-fast in static analysis.

import Model from "@expressive/mvc";

class FunActivity extends Model {
  /** Interval identifier for cleaning up */
  interval: number;

  /** Number of seconds that have passed */
  secondsSofar: number;

  constructor(alreadyMinutes: number = 0){
    super();

    this.secondsSofar = alreadyMinutes * 60;
    this.interval = setInterval(() => this.secondsSofar++, 1000)
  }

  /** JSDocs too can help provide description beyond simple 
   * autocomplete, making it easier reduce, reuse and repurpose. */
  willUnmount(){
    clearInterval(this.interval)
  }
}
const PaintDrying = ({ alreadyMinutes }) => {
  /* Your IDE will know `alreadyMinutes` is supposed to be a number */
  const { secondsSofar } = FunActivity.use(alreadyMinutes);

  return (
    <div>
      I've been staring for like, { secondsSofar } seconds now, 
      and I'm starting to see what this is all about! 👀
    </div>
  )
}

Controllers use a subscription model to decide when to render, and will only refresh for values which are actually used. They do this by watching property access on the first render, within a component they hook up to.

That said, while hooks can't actually read your function-component, destructuring is a good way to get consistent behavior. Where a property is not accessed on initial render render (inside a conditional or ternary), it could fail to update as expected.

Destructuring pulls out properties no matter what, and so prevents this problem. You'll also find also reads a lot better, and promotes good habits.

class FooBar {
  foo = "bar"
  bar = "foo"
}

const LazyComponent = () => {
  const { set, foo } = use(FooBar);

  return (
    <h1 
      onClick={() => set.bar = "baz" }>
      Foo is {foo} but click here to update bar!
    </h1>
  )
}

Here LazyComponent will not update when bar does change, because it only accessed foo here.

Changes made synchronously are batched as a single new render.

class ZeroStakesGame extends Model {
  foo = "bar"
  bar = "baz"
  baz = "foo"

  shuffle = () => {
    this.foo = "???"
    this.bar = "foo"
    this.baz = "bar"

    setTimeout(() => {
      this.foo = "baz"
    }, 1000)
  }
}
const MusicalChairs = () => {
  const { foo, bar, baz, shuffle } = ZeroStakesGame.use();

  return (
    <div>
      <span>Foo is {foo}'s chair!</span>
      <span>Bar is {bar}'s chair!</span>
      <span>Baz is {baz}'s chair!</span>

      <div onClick={shuffle}>🎶🥁🎶🎷🎶</div>
    </div>
  )
}

Even though we're ultimately making four updates, use() only needs to re-render twice. It does so once for everybody (being on the same event-loop), resets when finished, and again wakes for foo when it decides settle in.

Set behavior for certain properties on classes extending Model.

While standard practice is for use to take all methods (and bind them), all properties (and watch them), there are special circumstances to be aware of.

Set behavior for certain properties on classes extending Controller.

While standard practice is for use to take all methods (and bind them), all properties (and watch them), there are special circumstances to be aware of.

set / get

  • Not to be confused with setters / getters, a circular reference to state
  • this is useful to access your state object while destructuring

export<T>(this: T, keys?: string[]): { [P in keyof T]: T[P] }

  • return a snapshot of live state

Lifecycle methods are called syncronously, via useLayoutEffect and thus may block or intercept a render. If you prefer to run side-effects rather, use corresponding events or define your method as async.

componentDidMount

  • use() will call this while internally mounting for the first time.

componentWillUnmount

  • use() will call this before starting to clean up.

componentWillRender(): void

  • Called every render.

componentWillMount(): void

  • Called on first render, prior to component being drawn on-screen.

componentWillUpdate(): void

  • Called every subsequent render of the same component.

License

MIT license.

0.52.1

12 days ago

0.52.0

13 days ago

0.51.2

20 days ago

0.51.1

20 days ago

0.51.0

22 days ago

0.50.1

23 days ago

0.50.0

4 months ago

0.49.0

4 months ago

0.48.0

4 months ago

0.47.0

4 months ago

0.46.0

5 months ago

0.44.0

5 months ago

0.45.0

5 months ago

0.43.3

5 months ago

0.43.1

5 months ago

0.43.2

5 months ago

0.43.0

5 months ago

0.42.0

5 months ago

0.41.0

5 months ago

0.40.0

5 months ago

0.39.0

5 months ago

0.38.0

5 months ago

0.36.3

6 months ago

0.36.2

6 months ago

0.36.1

7 months ago

0.36.0

7 months ago

0.35.5-alpha.0

7 months ago

0.37.0

5 months ago

0.33.0

10 months ago

0.34.0

9 months ago

0.35.5

7 months ago

0.35.2

8 months ago

0.35.1

8 months ago

0.35.0

8 months ago

0.35.0-alpha.0

8 months ago

0.35.6

7 months ago

0.32.0

11 months ago

0.31.0

11 months ago

0.20.0

1 year ago

0.17.0

1 year ago

0.17.1

1 year ago

0.29.0

11 months ago

0.21.8

12 months ago

0.21.7

12 months ago

0.25.0

12 months ago

0.21.3

12 months ago

0.21.2

1 year ago

0.21.1

1 year ago

0.29.2

11 months ago

0.29.1

11 months ago

0.21.0

1 year ago

0.18.1

1 year ago

0.18.0

1 year ago

0.26.0

12 months ago

0.22.0

12 months ago

0.19.0

1 year ago

0.19.2

1 year ago

0.30.0

11 months ago

0.27.0

12 months ago

0.23.0

12 months ago

0.28.0

11 months ago

0.24.2

12 months ago

0.24.1

12 months ago

0.24.0

12 months ago

0.16.0

1 year ago

0.15.2

1 year ago

0.13.0

1 year ago

0.13.1

1 year ago

0.9.4

1 year ago

0.9.3

1 year ago

0.9.6

1 year ago

0.9.5

1 year ago

0.10.1

1 year ago

0.14.0

1 year ago

0.10.0

1 year ago

0.8.1

1 year ago

0.8.0

1 year ago

0.8.2

1 year ago

1.0.0

1 year ago

0.11.0

1 year ago

0.11.1

1 year ago

0.15.0

1 year ago

0.15.1

1 year ago

0.9.0

1 year ago

0.9.2

1 year ago

0.9.1

1 year ago

0.12.0

1 year ago

0.7.2

2 years ago

0.7.1

2 years ago

0.5.0

2 years ago

0.7.0

2 years ago

0.6.3

2 years ago

0.4.5

2 years ago

0.6.2

2 years ago

0.4.4

2 years ago

0.4.7

2 years ago

0.4.6

2 years ago

0.4.1

2 years ago

0.4.0

2 years ago

0.6.1

2 years ago

0.4.3

2 years ago

0.6.0

2 years ago

0.4.2

2 years ago

0.1.0

2 years ago

0.3.0

2 years ago

0.1.1

2 years ago

1.0.0-beta.44

2 years ago

1.0.0-beta.45

2 years ago

1.0.0-beta.42

2 years ago

1.0.0-beta.43

2 years ago

1.0.0-beta.40

2 years ago

1.0.0-beta.41

2 years ago

0.0.1

2 years ago

0.2.1

2 years ago

0.0.3

2 years ago

0.2.0

2 years ago

0.0.2

2 years ago

0.2.6

2 years ago

1.0.0-beta.39

2 years ago

0.2.3

2 years ago

0.2.2

2 years ago

0.0.4

2 years ago

1.0.0-beta.38

2 years ago

0.2.5

2 years ago

0.2.4

2 years ago

1.0.0-beta.37

2 years ago

1.0.0-beta.34

2 years ago

1.0.0-beta.35

2 years ago

1.0.0-beta.36

2 years ago

1.0.0-beta.33

2 years ago

1.0.0-beta.28

2 years ago

1.0.0-beta.29

2 years ago

1.0.0-beta.26

2 years ago

1.0.0-beta.27

2 years ago

1.0.0-beta.25

2 years ago

1.0.0-beta.31

2 years ago

1.0.0-beta.32

2 years ago

1.0.0-beta.30

2 years ago

1.0.0-beta.22

2 years ago

1.0.0-beta.23

2 years ago

1.0.0-beta.21

2 years ago

1.0.0-beta.24

2 years ago

1.0.0-beta.20

2 years ago

1.0.0-beta.19

2 years ago

1.0.0-beta.17

2 years ago

1.0.0-beta.18

2 years ago

1.0.0-beta.15

3 years ago

1.0.0-beta.13

3 years ago

1.0.0-beta.14

3 years ago

1.0.0-beta.12

3 years ago

1.0.0-beta.11

3 years ago

1.0.0-beta.10

3 years ago

1.0.0-beta.9

3 years ago

1.0.0-beta.8

3 years ago

1.0.0-beta.7

3 years ago

1.0.0-beta.6

3 years ago

1.0.0-beta.5

3 years ago

1.0.0-beta.4

3 years ago

1.0.0-beta.3

3 years ago

1.0.0-beta.2

3 years ago

1.0.0-beta.1

3 years ago

1.0.0-beta.0

3 years ago