Getting Started
React

Getting started with React

Expressive MVC is architected to be as simple as possible, and needs little to no setup. Model handles reactivity "under the hook" to accomplish most tasks.

Install @expressive/react

npm i @expressive/react

Import and extend Model

Define any values you wish to track within a given component.

  import Model from "@expressive/react";
 
  class Counter extends Model {
    current = 1
 
    increment = () => this.current += 1;
    decrement = () => this.current -= 1;
  }

Here we'll also define some methods. This isolates the logic for incrementing and decrementing the counter, so it doesn't need to live in the component.

Use model as a hook

Static method use() (inherited from Model) is hook which will create, memoize and subscribe to all state used by this component.

  function MyCounter(){
    const { current, increment, decrement } = Counter.use();
 
    return (
      <div>
        <button onClick={decrement}>{"-"}</button>
        <pre>{current}</pre>
        <button onClick={increment}>{"+"}</button>
      </div>
    )
  }

Place it in your app

We already have something usable! 🚀

1

View in CodeSandbox (opens in a new tab)


Migrating from useState

useState is pretty straightforward as hooks go, but it does not scale well. Often there's repetition and not immediately clear what is intended behavior. Lot of closures get created too, which hurts performance if the component renders often.

  function Component(){
    const [foo, setFoo] = useState(0);
    const [bar, setBar] = useState(0);
    const [baz, setBaz] = useState(0);
 
    return (
      <div>
        <button onClick={() => setFoo(x => x+1)}>Foo is {foo}</button>
        <button onClick={() => setBar(x => x+1)}>Bar is {bar}</button>
        <button onClick={() => setBaz(x => x+1)}>Baz is {baz}</button>
      </div>
    )
  }

First combine foo, bar and baz into a single hook using Model. The built-in is property will help with assignment. It's added to any observable (model) when accessed by a subscriber (component). Just a reference to the original instance, it allows for assignment after destructuring.

  class Control extends Model {
    foo = 0;
    bar = 0;
    baz = 0;
  }
 
  function Component(){
    const { is: control, foo, bar, baz } = Control.use();
 
    return (
      <div>
        <button onClick={() => control.foo++}>Foo is {foo}</button>
        <button onClick={() => control.bar++}>Bar is {bar}</button>
        <button onClick={() => control.baz++}>Baz is {baz}</button>
      </div>
    )
  }

Because we're assigning directly to control, the values will always be up-to-date; we never need to worry about dispatch functions or stale closures.


Updating internally

While you can update from the component, but what about the model itself? Since state is on the same instance, just add methods and assign to this.

  class Control extends Model {
    foo = 1;
    bar = 2;
    baz = 3;
 
    increment = () => {
      this.foo += 1;
      this.bar += 2;
      this.baz += 3;
    }
  }

Now, from in the component, call a method instead and all properties will update. Notice though, we implement increment as an arrow-function to ensure this is bound when passed to onClick.

function Counter() {
  const { foo, bar, baz, increment } = Control.use();
 
  return (
    <div>
      <span>Foo is {foo}</span>
      <span>Bar is {bar}</span>
      <span>Baz is {baz}</span>
      <button onClick={increment}>
        Add one to foo, bar and baz!
      </button>
    </div>
  );
}

Async control

Since events come from assignment, it's not a huge leap that async stuff is pretty easy. Let's do something practical, like say hello to our API and await a response. We only need to track three things - the response, whether we're waiting, and if there was an error.

class Service extends Model {
  response = undefined;
  waiting = false;
  error = false;
 
  sayHello = async () => {
    this.waiting = true;
 
    try {
      const res = await fetch("http://service.com/hello");
      this.response = await res.text();
    }
    catch(e) {
      this.error = true;
    }
  }
}

Now we simply use it in a component. Said component needs zero logic or state of its own.

function HelloWorld(){
  const { error, response, waiting, sayHello } = Service.use();
 
  if(response)
    return <p>Server said: {response}</p>
 
  if(error)
    return <p>There was an error saying hello.</p>
 
  if(waiting)
    return <p>Sent! Waiting on response...</p>
 
  return (
    <a onClick={sayHello}>Say hello to server!</a>
  )
}

If this were all we needed, installing an entire library like SWR (opens in a new tab) (263 kB) or react-query (opens in a new tab) (2.26 MB), would be massive overkill. We can do it ourselves with just a few lines of code!

For reference, all of @expressive/react is a hair under 100 kB. 👀


Reusable Models

Custom models, being classes, can be further extended as your own reusable models. When using purpose-built libraries, the main benefit is abstraction. Our previous example does its own fetching, and great as a one-off, but to do that in multiple places we should make it more generic.

Highlighted are changes to the example above.

Query.ts
import { toQueryString } from "helpers";
import { User } from "types";
 
abstract class Query<T = any> extends Model {
  /* instead of hard-coding this, let's make it a parameter */
  abstract url: string;
 
  /* Let's add a query feature to our example. */
  query?: { [param: string]: string | number };
 
  error?: Error = undefined;
  response?: T = undefined;
  waiting = false;
 
  /* This too could be overridden, to override or format response. */
  protected async request(): Promise<T> {
    let { url, query } = this;
 
    if(query)
      url += toQueryString(query);
 
    const res = fetch(url);
    return await res.json();
  }
 
  fetch = async () => {
    this.waiting = true;
 
    this.request()
      .then(res => this.response = res)
      .catch(e => this.error = e)
      .finally(() => this.waiting = false);
  }
}

Now with an abstract Query meant to be extended, Users can be customized using only two properties! This allows us not to place configuration data in the component, and instead it lives in the model.

User.ts
class Users extends Query {
  url = "http://service.com/getUsers";
  query = {
    name: "John",
    age: 25
  };
}
 
function UserList(){
  const { error, response, waiting, fetch } = Users.use();
 
  if(response)
    return <p>Users: {response}</p>
 
  if(error)
    return <p>There was an error fetching users.</p>
 
  if(waiting)
    return <p>Waiting for users...</p>
 
  return (
    <a onClick={fetch}>Get users!</a>
  )
}

However, you can still place configuration data in the component if you want to. Let's place url and query as properties of an object passed into Query.use().

User.ts
function UserList(){
  const { error, response, waiting, fetch } = Query.use({
    url: "http://service.com/getUsers",
    query: {
      name: "John",
      age: 25
    }
  });
 
  if(response)
    return <p>Users: {response}</p>
 
  if(error)
    return <p>There was an error fetching users.</p>
 
  if(waiting)
    return <p>Waiting for users...</p>
 
  return (
    <a onClick={fetch}>Get users!</a>
  )
}