2.3.0 β€’ Published 4 years ago

casium v2.3.0

Weekly downloads
12
License
Apache-2.0
Repository
github
Last release
4 years ago

Casium: An application architecture for React

The Casium Front-End Architecture is a data and effects management system that helps you manage the complexity of large React applications reliabily and predictably.

It does this by modeling the state of your application (yes, all of it) as a single, immutable value, and handling side-effects in application logic with messages. If this reminds you of Redux, that might be because both are derived from The Elm Architecture. However, this library attempts to hew more closely to Elm's design in order to gain more of Elm's advantages, and to provide a better, more cohesive developer experience.

How to structure applications

Applications implemented on Casium are organized into two different types of React components:

  • Containers (or container components), which are implemented using a function exported by Casium: containers manage your application's data model, how it changes, and other application logic
  • Views (or view components), which are normal React stateless functional components: they receive the data model managed by the container, as well as a special function to communicate events back to the container

In other words...

class Never extends Component { /* πŸ˜‰ */ }

Because Casium manages application state and lifecycle events for us, we should (almost) never need to implement a class-based React component.

The counter example

The basic implementation of a container looks like this:

import React from 'react';

import Message from 'casium/message';
import { container } from 'casium';

class Increment extends Message {}
class Decrement extends Message {}

export default container({

  init: () => ({ count: 0 }),

  update: [
    [Increment, ({ count }) => ({ count: count + 1 })],
    [Decrement, ({ count }) => ({ count: count - 1 })]
  ],

  view: ({ emit, count }) => (
    <div>
     <button onClick={emit(Decrement)}> - </button>
     {count}
     <button onClick={emit(Increment)}> + </button>
    </div>
  )
});

Containers are composed primarily of 3 things:

  • init: A function that returns the container's initial model state. This is called when the container is first instantiated, and should set up the default values for all model values to be used in the container. This should be a plain JavaScript Object. (This function will sometimes take a parameter if part of the model is being delegated to it from higher upβ€”we'll get to that later). In general, containers shouldn't have to check whether values exist. If they do, you should probably update your init function.
  • update: A list of pairs that combine a message with an update handler (or updater). This is where the action happens. All changes to the application, whether model changes or side-effects (like HTTP calls) happen via messages. This allows the entire model and lifecycle of the application to decompose to simple data structures. Containers intercept messages, and call the updater for that message with the current model as a parameter. The updater returns the new model. Finally, the container re-renders the view with the new model.
  • view: The view is a stateless React component that receives the container's current model as its props (as well as any other props passed to the container by React). In addition to the model, it also receives one special value, emit, which is a function that accepts a message and gets assigned to an event handler. This is what allows the container to be notified about events that we care about and respond with the appropriate updater.

Messages

You may have also noticed the Increment and Decrement classes up at the top that extend Message. As you probably guessed, those are the messages referenced in the explanation above. Messages are a powerful abstraction, but right now we're just using them to link an event handler to an update we want to apply.

Messages with data

In the initial example, we completed the cycle of...

  1. Setting up the container with an initial model from init
  2. Rendering the view with the initial model
  3. Receiving messages from the view
  4. Handling the messages in update
  5. Updating the model with the Increment or Decrement messages and their respective updaters
  6. Re-rendering the view
  7. Go to (3)

It looks a bit like this...

Simple Architecture

This is a good start, but it's somewhat limiting. What if we need to step the counter by 100, or 1000? That's a lot of clicking. We could create up/down messages & buttons for powers of 10, but that's a lot of boilerplate.

Instead, we can turn the counter display into an <input /> to allow users to type in arbitrary values, and wire the input up to the container with a new message:

class Increment extends Message {}
class Decrement extends Message {}
class SetCounter extends Message {} // <-- New thing

export default container({

  init: () => ({ count: 0 }),

  update: new [
    [Increment, ({ count }) => ({ count: count + 1 })],
    [Decrement, ({ count }) => ({ count: count - 1 })],
    [SetCounter, (model, { value }) => ({ count: parseInt(value, 10) })]
  ],

  view: ({ emit, count }) => (
    <div>
      <button onClick={emit(Decrement)}> - </button>

      <input
        type="number"
        value={count}
        onChange={emit(SetCounter)}
      />

      <button onClick={emit(Increment)}> + </button>
    </div>
  )
});

We've created a new message, SetCounter, to handle our new event. You'll also notice that the updater for SetCounter looks a bit different from the ones before. In addition to being typed objects, messages hold data, either from the view, or from commands (we'll get to that later). In the previous examples, none of the messages used dataβ€”we just took in the existing model, destructured it to the value(s) we cared about, and returned a new model.

Updaters receive message data as their second parameter, and we can likewise destructure the part we care about, use it to calculate a new model, and return said model.

By default, messages emitted from DOM events will have value and checked properties, which will match the properties of the element emitting the event, for convenience purposes.

Parameterizing messages

Suppose all that typing is just too much, and we decide we did want buttons to step our counter by larger increments. We can add parameters to our messages directly in the view, and refactor our existing updaters to accomodate them.

First, we'll add default values to our messages:

class Increment extends Message {
  static defaults = { step: 1 };
}
class Decrement extends Message {
  static defaults = { step: 1 };
}

This means that these messages will always pass a step value to their update handlers: either one provided by the view, or the default value of 1. Next, we'll override the default step value in the view, by adding a new pair of buttons:

export default container({

  // ...

  view: ({ emit, count }) => (
    <div>
      <button onClick={emit([Decrement, { step: 10 }])}> -- </button>
      <button onClick={emit(Decrement)}> - </button>

      <input
        type="number"
        value={count}
        onChange={emit(SetCounter)}
      />

      <button onClick={emit(Increment)}> + </button>
      <button onClick={emit([Increment, { step: 10 }])}> ++ </button>
    </div>
  )
});

We can see in the new ++ and -- buttons that the format for calling emit() has changed: instead of just passing a message class, we're now passing an array with a message class and an object with some values. These values will be merged with any other data received from the view when constructing the message.

Finally, we need to close the loop and modify our update handlers to accept the step value. As above, updaters receive message data as their second parameter. Since we only care about step, we'll destructure it directly:

export default container({

  // ...

  update: [
    [Increment, ({ count }, { step }) => ({ count: count + step })],
    [Decrement, ({ count }, { step }) => ( count: count - step })],
    [SetCounter, (model, { value }) => ({ count: parseInt(value, 10) })]
  ],

  // ...
});

Now the counter is updating according to step, and we can reuse the same message in different contexts by parameterizing it appropriately. Next we'll look at how we can leverage and extend messages to encapsulate business logic and reduce updater boilerplate.

Talking to the outside world

So far we've looked at managing and rendering data within an application. That's a good start, but in order to do something useful, we need to step outside our cozy, stable world of immutable data and interact with very mutable, unstable things. Things like servers, and browser services like cookies and local storage.

These interactions are called side-effects, and side effects trip us up because they break our guarantees about the state of our application. Up till now, we've been able to model the state of our application with a very simple equation: current = initial + (messages...) β€” in other words, each model state is the sum of the one before it, plus the most recent message, going all the way back to the initial model state.

Lifestyles of the rich and the stateless

Think of it like a bank ledger: the current balance is just a sum of all the transactions. However, when we introduce side-effects, our bank balance starts changing out from under us. We no longer have an audit log of what it should be or why. We have no way to predict it, and we've lost the ability to reproduce it or examine it in an isolated context.

So, how do we make our state predictable again? With more messages, of course!

Up until now, we've been both producing (in the view) and consuming (in the update) our own messages. Command messages (or just commands) are a new type of message: we produce them, but they're consumed by Casium, in the background, away from our application code. Casium manages our effects for us. We use these commands any time we want to read, write, or execute something outside of our model, like HTTP calls, cookies, etc.

As with model changes, commands are returned by update handlers. This allows update handlers to be pure, stateless, side-effect-free functions: they always return the same value for the given inputs.

Further, they don't actually do things: the simply return values that represent doing things. Let's test-drive this by implementing a button to save the counter to local storage. We'll start by importing the necessary commands. Casium comes pre-packaged with commands for most common operations.

We can import and use these commands to tell Casium what effects we want, and Casium will handle them for us:

import { LocalStorage } from 'casium/commands';

This imports a module object with a few different classes for our consumption.

Next, we'll implement a button to trigger our save, with a new SaveCounter message, and add a placeholder update handler where we'll eventually put our new command:

// ...
class SaveCounter extends Message {}

export default container({
  // ...

  update: [
    // ...
    [SaveCounter, (model) => model]
  ],

  view: ({ emit, count }) => (
    <div>
      // ...
      <button onClick={emit(SaveCounter)}>Save</button>
    </div>
  )
})

This doesn't do anything yet (except render a new button), but we can already see that something with the update handler is a little different: we're still returning the model, but now it's inside an array. This gives us a place to put our commands, since we'll be returning two things instead of one. Let's add that second thing now:

// ...
export default container({
  // ...

  update: [
    // ...
    [SaveCounter, (model) => [model, new LocalStorage.Write({
      key: 'counter',
      value: model.count
    })]]
  ],

  // ...
})

Here, we're returning a new instance of the LocalStorage.Write command message, and giving it a key and a value, which tells local storage what to write.

This is all well and good for fire-and-forget operations like writing to local storage, but what about reading? What about commands that do things where we care about the result?

We handle this by giving the command a result message. These are normal messages that we implement and handle ourselves, just like the ones emitted from the view. We pass one of these messages to the command in a result key, and Casium will send that message back to our view when the command has executed.

This extends the cycle of our data flow like so:

Architecture with Commands

In addition to the primary cycle of messages and model changes (and rendering, etc.), we now have a secondary cycle where updaters also return commands (in addition to updating the model), which send messages back to an updater.

This style might feel weird if you're used to using promises or callbacks. Promises and callbacks make it easy to do long chains of deeply-nested behaviors, that can be tough to decompose when debugging, testing, or refactoring.

This approach lets us flatten out those chains, letting us handle one logical update at a time, in isolation from others.

Let's try loading the counter back from local storage when the container initializes. Because we don't want to break our isolation boundary by touching local storage directly, we'll have init() kick off a command that will be immediately handled and returned to the container in the form a result message (which we'll define below as LoadCounter).

The expected return value format for the init() function is actually the same as for update handlers, so we can implement this just by changing what it returns:

// ...
class LoadCounter extends Message {}

export default container({

  init: () => [{ count: 0 }, new LocalStorage.Read({
    key: 'counter',
    result: LoadCounter
  })],

  update: [
    [LoadCounter, (model, { value }) => ({ count: value })],
    // ...
  ],

  // ...
})

Sidebar: Updater Return Formats ⬇

Updaters support a number of different return formats to enable different programming patterns:

  • Just the model: Returning an object will replace the current model with that object
  • A model and a command: As in init above, a 2-element array (or tuple) is used to update the model and run a command
  • A model and multiple commands: The above pattern can be extended to run multiple commands, just keep appending commands to the array
  • A model and multiple commands, part deux: Sometimes it's easier to put commands together in their own arrayβ€”return a tuple where the first element is the model and the second is an array of any number of commands
  • Update-ception: Finally, in addition to the forms above, an updater function can return another updater function... which can return another updater function (and so on)β€”the functions will continue to be called until one of the above formats is returned; this is useful for advanced function composition techniques

Again, we're changing the return value of init() to the array format we saw before, so that we have a way to return both our initial model and the command. We construct a Read message with the key we want to read, and result, which is the message that will be sent back to the container with, you guessed it: the result. This is symmetrical to the Write command, except that the key and the value are spread across the command and result message, respectively.

We handle our LoadCounter message, destructuring the value property and assigning it to the count property of the new model. Great, right?

Well, almost. Don't look now, but we've just introduced an error into our app: count is supposed to be a number, but local storage returns strings. We need to pass value through parseInt() and... this is starting to look a lot like work we've already done.

Instead, we can reuse our existing SetCounter message β€” both messages expect to have a value which gets number-ified and written to the count property of the model.

Altogether, our app should look something like this:

import React from 'react';

import Message from 'casium/message';
import { container } from 'casium';
import { LocalStorage } from 'casium/commands';

class Increment extends Message { static defaults = { step: 1 } }
class Decrement extends Message { static defaults = { step: 1 } }
class SetCounter extends Message {}

export default container({

  init: () => [{ count: 0 }, new LocalStorage.Read({
    key: 'counter',
    result: SetCounter
  })],

  update: [
    [Increment, ({ count }, { step }) => ({ count: count + step })],
    [Decrement, ({ count }, { step }) => ( count: count - step })],
    [SetCounter, (model, { value }) => ({ count: parseInt(value, 10) })],
    [SaveCounter, (model) => [model, new LocalStorage.Write({
      key: 'counter',
      value: model.count
    })]]
  ],

  view: ({ emit, count }) => (
    <div>
      <button onClick={emit([Decrement, { step: 10 }])}> -- </button>
      <button onClick={emit(Decrement)}> - </button>

      <input
        type="number"
        value={count}
        onChange={emit(SetCounter)}
      />

      <button onClick={emit(Increment)}> + </button>
      <button onClick={emit([Increment, { step: 10 }])}> ++ </button>

      <button onClick={emit(SaveCounter)}>Save</button>
    </div>
  )
});

Conditional command values

In order to avoid unwieldy if blocks to return different forms when you only want to run a command sometimes, you're able to use null, false or undefined (they're all equivalent) anywhere a command is accepted. Suppose you only wanted to persist the counter for only the most committed of users, let's say when it's over 9000. You could rewrite the above as follows:

[SaveCounter, (model) => [
  model,
  model.count > 9000 && new LocalStorage.Write({
    key: 'counter',
    value: model.count
  })
]]

Unless count > 9000, the overall expression of the second array element evalutes to false, and the Write command won't get run.

Growing applications

@TODO: Container composition, delegation, relay, etc.

Extending messages

@TODO:

  • Subclassing messages
  • Constructor logic
  • etc.

Extending commands

@TODO: Explanation on extending commands...

import { Post, formData } from 'casium/commands/http';

export default class SignIn extends Post {

  constructor({ email, password, ...values }) {
    const clientId = 'my-app';
    const clientSecret = 'woo-sekrit';

    super({
      url: '/oauth/token',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
        'Authorization': 'Basic ' + btoa(`${clientId}:${clientSecret}`)
      },
      data: formData({
        username: email,
        password,
        grant_type: 'password',
        scope: 'read write',
        client_secret: clientSecret,
        client_id: clientId
      }),
      ...values
    });
  }
}

Why immutability and managed effects?

As we talked about, all changes in the application are handled by messages. Messages are either emitted from the view, or the result of a command message (or just command).

@TODO

Writing tests

Here's what an example unit test for the first draft of our counter container might look like. Note that we're not making any assertions about the view, just the updates and the container's model.

import { isolate } from 'casium';
import CounterContainer, { Increment, Decrement } from './';

describe('CounterContainer', () => {
  const container = isolate(CounterContainer);

  beforeEach(() => container.push({ count: 0 }))

  describe('model changes', () => {

    it('should increment', () => {
      container.dispatch(new Increment());
      expect(container.state()).to.deep.equal({ count: 1 });
    });

    it('should decrement', () => {
      container.dispatch(new Decrement());
      expect(container.state()).to.deep.equal({ count: -1 });
    });
  });
});

Testing commands

@TODO

describe('CounterContainer', () => {
  // ...

  describe('local storage handling', () => {

    it('should save counter', () => {
      container.push({ count: 1138 });
      const cmds = container.dispatch(new SaveCounter());

      expect(cmds).to.deep.equal([
        new LocalStorage.Write({ key: "counter", value: 1138 })
      ]);
    });
  });
});

Subscriptions

@TODO

Adding custom effects

@TODO

Customizing the environment

@TODO

Adding types with TypeScript

@TODO

Development

Getting Started

yarn peers
yarn test

Using in a local app

yarn build
cd dist
yarn link

Now you can install your local build of Casium in a web application.

yarn add casium

And do development on the Casium codebase while seeing those changes in your web application.

yarn dev
# Or if you don't want to run the unit tests every time,
yarn dev/fast
2.3.0

4 years ago

2.2.4

4 years ago

2.2.3

4 years ago

2.2.2

4 years ago

2.2.1

6 years ago

2.2.0

6 years ago

2.1.4

6 years ago

2.1.3

6 years ago

2.1.2

6 years ago

2.1.1

6 years ago

2.1.0

6 years ago

2.0.1

6 years ago

2.0.0

6 years ago

1.1.8

6 years ago

1.1.7

6 years ago

1.1.6

6 years ago

1.1.5

6 years ago

1.1.4

6 years ago

1.1.3

6 years ago

1.1.2

6 years ago

1.2.1

6 years ago

1.2.0

6 years ago

1.0.11

6 years ago

1.0.10

6 years ago

1.0.9

6 years ago

1.0.8

6 years ago

1.0.7

6 years ago

1.0.6

6 years ago

1.0.5

6 years ago

1.0.2

6 years ago

1.0.4

6 years ago

1.0.3

6 years ago

1.0.1

6 years ago

1.0.0

6 years ago