Reusable Logic

This guide describes additional APIs as well as design patterns for developers who wish to reuse logic between components or write Crank-specific libraries.

Additional Context Methods and Properties

Crank provides several additional methods and properties via the Context API to help you share logic between components. Some of the APIs demonstrated here are for library authors and should not be used in the course of typical application development.

context.props

The current props of a component can be accessed via the readonly context property props. We recommended that you access props within components via its parameters or context iterators when writing components. The props property can be useful when you need to access a component’s current props from within an extension or helper function.

context.value

Similarly, the most recently rendered value of a component is accessible via the readonly context property value. Again, we recommend that you access rendered values via the many methods described in the guide on accessing rendered values or via the crank-ref prop, but it can be useful to access the current value synchronously when writing helper context methods.

Depending on the state of the component, the accessed value can be an node, a string, an array of nodes and strings, or undefined.

context.provide and context.consume

Warning: This API is more unstable than others, and the method names and behavior of components which use this method may change.

Crank allows you to provide data to all of a component’s descendants via the methods provide and consume. The provide method sets a “provision” under a specific key, and the consume method retrieves the value set under a specific key by the nearest ancestor.

function GreetingProvider({greeting, children}) {
  this.provide("greeting", greeting);
  return children;
}

function Greeting({name}) {
  const greeting = this.consume("greeting");
  return <p>{greeting}, {name}</p>;
}

function* App() {
  return (
    <div>
      <Greeting name="Brian" />
    </div>
  );
}

renderer.render(
  <GreetingProvider greeting="Hello">
    <App />
  </GreetingProvider>,
  document.body,
);

console.log(document.body); // "<div><p>Hello, Brian</p></div>"

Provisions allow libraries to define components which interact with their descendants without rigidly defined component hierarchies or requiring data to be passed manually between components via props. This makes them useful, for instance, when writing multiple components which communicate with each other, like custom select and option form elements, or drag-and-drop components.

Anything can be passed as a key to the provide and consume methods, so you can use a symbol to ensure that the provision you pass between your components are private and do not collide with provisions set by others.

Note: Crank does not link “providers” and “consumers” in any way, and doesn’t automatically refresh consumer components when the provide method is called. It’s up to you to ensure consumers update when providers update.

context.schedule

You can pass a callback to the schedule method to listen for when the component renders. Callbacks passed to schedule fire synchronously after the component commits, with the rendered value of the component as its only argument. Scheduled callbacks fire once per call and callback function (think requestAnimationFrame, not setInterval). This means you have to continuously call the schedule method for each update if you want to execute some code every time your component commits.

context.cleanup

Similarly, you can pass a callback to the cleanup method to listen for when the component unmounts. Callbacks passed to cleanup fire synchronously when the component is unmounted. Each registered callback fires only once. The callback is called with the last rendered value of the component as its only argument.

Strategies for Reusing Logic

The following are various patterns you can use to write and reuse logic between components, as well as a description of their tradeoffs. We will be wrapping the global window.setInterval function in examples to demonstrate each design pattern.

Global Context Extensions

You can import and extend the Context class’s prototype to globally extend all contexts in your application.

import {Context} from "@bikeshaving/crank";

const ContextIntervalSymbol = Symbol.for("ContextIntervalSymbol");

Context.prototype.setInterval = function(callback, delay, ...args) {
  const interval = window.setInterval(callback, delay, ...args);
  if (typeof this[ContextIntervalSymbol] === "undefined") {
    this[ContextIntervalSymbol] = new Set();
    this.cleanup(() => {
      for (const interval of this[ContextIntervalSymbol]) {
        window.clearInterval(interval);
      }
    });
  }

  this[ContextIntervalSymbol].add(interval);
};

Context.prototype.clearInterval = function(interval) {
  if (typeof this[ContextIntervalSymbol] !== "undefined") {
    this[ContextIntervalSymbol].delete(interval);
  }

  window.clearInterval(interval);
}

function *Counter() {
  let seconds = 0;
  this.setInterval(() => {
    seconds++;
    this.refresh();
  }, 1000);

  while (true) {
    yield <div>Seconds: {seconds}</div>;
  }
}

In this example, we define the methods setInterval and clearInterval directly the Context prototype. The setInterval and clearInterval methods will now be available to all components.

Pros:

Cons:

Global context extensions are useful for creating Crank-specific wrappers around already global or otherwise well-known APIs like setInterval, requestAnimationFrame or fetch.

Context Helper Utilities

As an alternative to global context extensions, you can write utility functions which are passed contexts to scope reusable logic per component. In the following example, instead of defining the setInterval method globally, we define it locally by passing the context of the component into the createSetInterval function.

function createSetInterval(ctx, callback, delay, ...args) {
  const interval = window.setInterval(callback, delay, ...args);
  ctx.cleanup(() => window.clearInterval(interval));
  return interval;
}

function *Counter() {
  let seconds = 0;
  const setInterval = createSetInterval(this);
  setInterval(() => {
    seconds++;
    this.refresh();
  }, 1000);

  while (true) {
    yield <div>Seconds: {seconds}</div>;
  }

}

Pros:

Cons:

Context helper utilities are useful when you want to write locally-scoped utilities, and are especially well-suited for use-cases which requires setup logic. Possible use-cases include wrappers around stateful APIs like mutation observers or HTML drag and drop.

Higher-order Components

Because Crank components are just functions, we can write functions which both take components as parameters and return wrapped component functions.

function interval(Component) {
  return function *WrappedComponent() {
    let seconds = 0;
    const interval = window.setInterval(() => {
      seconds++;
      this.refresh();
    }, 1000)
    try {
      for (const props of this) {
        yield <Component seconds={seconds} {...props}/>;
      }
    } finally {
      window.clearInterval(interval);
    }
  };
}

const Counter = interval((props) => <div>Seconds: {props.seconds}</div>);

The interval function takes a component function and returns a component which passes the number of seconds as a prop. Additionally, it refreshes the returned component whenever the interval is fired.

Pros:

Cons:

The main advantage of higher-order components is that you can respond to props in your utilities just like you would with a component. Higher-order components are most useful when you need reusable logic which responds to prop updates or sets only well-known props. Possible use-cases include styled component or animation libraries.

Async Iterators

Because components can be written as async generator functions, you can integrate utility functions which return async iterators seamlessly with Crank. Async iterators are a great way to model resources because they define a standard way for releasing resources when they’re no longer needed by returning the iterator.

async function *createInterval(delay) {
  let available = true;
  let resolve;
  const interval = window.setInterval(() => {
    if (resolve) {
      resolve(Date.now());
      resolve = undefined;
    } else {
      available = true;
    }
  }, delay);

  try {
    while (true) {
      if (available) {
        available = false;
        yield Date.now();
      } else {
        yield new Promise((resolve1) => (resolve = resolve1));
      }
    }
  } finally {
    window.clearInterval(interval);
  }
}

async function *Counter() {
  let seconds = 0;
  for await (const _ of createInterval(1000)) {
    yield <div>Seconds: {seconds}</div>;
    seconds++;
  }
}

Pros:

Cons:

If you use async iterators/generators already, Crank is the perfect framework for your application.