DEV Community

Paul Johnson
Paul Johnson

Posted on • Edited on

Hacn: React components using javascript generators.

Hacn

Hacn is a "skin" over React components that provides a powerful approach to programming UI's using javascript generators. Its heavily inspired by react hooks and a concept called algebraic effects and is very similar to redux-saga.

Hacn is hard to explain, so it's easier to start with an example:

Let's say we want to show a loading element while we fetch some data. To make it look nice we don't want to show the loading element at all if the fetch is quick. We also don't want to flash the loading element on and off the screen rapidly if the data loads shortly after we start displaying the loading element.

The logic in psuedo javascript is roughly:

show(null);
const loading = fetch("https://example.org/data");

const finished_first = any(loading, timeout(100));
if (finished_first === "timeout") {
  show(<div>Loading...</div>);
  timeout(200);
  wait(loading);
}

show(<div>{loading.data}</div>);

To implement this in a react component using hooks you might do something like:

const Loading = () => {
  const [data, setData] = useState(null);
  const [show, setShow] = useState("starting");

  useEffect(() => {
    if (show === "starting") {
      fetch("https://example.org/data", (data) => {
        setData(data);
        if (show !== "loading") {
          setShow("done");
        }
      });
      setTimeout(() => {
        if (data === null) {
          setShow("loading");
          setTimeout(() => {
            if (data) {
              setShow("done");
            }
          }, 200);
        }
      }, 100);
    }
  }, [show]);

  if (show === "starting") {
    return null;
  }

  if (show === "loading") {
    return <div>Loading</div>;
  }

  return <div>{data}</div>;
};

In Hacn this becomes:

const Loading = hacn(function* (props) {
    yield _continue(null);

    const fetchEffect = yield _continue(json'https://example.org/data');

    const firstTimeout = yield _continue(timeout(100));

    let [data, _] = yield any(fetchEffect, firstTimeout);

    if (!data) {
        yield _continue(<div>Loading...</div>);
        yield timeout(200);
        data = yield suspend(fetchEffect);
    }

    return (<div>{data}</div>);
});

There's a lot going on here, but it should be obvious how Hacn transforms a complex useEffect handler into a simple linear sequence of events.

I'll explain line by line what's happening:

const Loading = hacn(function* (props) {

To create a Hacn component you pass a javascript generator to the hacn function. Generators are usually explained as a technique for looping over arrays and other structures without creating intermediate arrays. But they are much more powerful than this, you can think of them as a construct that lets you pause and save a function in the middle of it's execution so it can be restarted later. Hacn uses this to save the executing function inside the state of a regular react component and resumes it every time react renders the component.

The yield statements throughout the function return objects called 'effects'. Effects instruct Hacn on what to do e.g. fetch some data or pause execution for a period of time.

yield _continue(null);

_continue is an effect that takes another effect and carries on executing the function, often performing some action as a side effect. null and jsx tags are treated as a special case and are transformed into the render effect, which is used to render results during execution.

const fetchEffect = yield _continue(json'https://example.org/data');
const firstTimeout = yield _continue(timeout(100));

json and timeout are effects that fetch data and start a timer respectively, we wrap them in _continue, because we don't want to wait for them to complete just yet. _continue effects generally return the wrapped effect, so that we can wrap the effect again later.

let [data, _] = yield any(fetchEffect, firstTimeout);

any is an effect that stops execution and restarts once one of the effects passed to it signals to Hacn that it is complete and it should continue executing. Effects default to suspending and have to be explicitly wrapped in _continue() to make them contine.

if (!data) {
  yield _continue(<div>Loading...</div>);
  yield timeout(200);
  data = yield suspend(fetchEffect);
}

This part checks if data has not returned, renders the loading message without waiting, suspends waiting for the timeout effect to finish and then suspends on the fetchEffect that was returned from the _continue(json...) call above.

return <div>{data}</div>;

Finally we render the data.

Capturing events

Handling events also works a little bit differently from in regular react, rather than a callback you use the capture parameter to return events from a render effect:

const Capture = hacn(function* (props, capture) {
  let enteredText = "";

  while (enteredText !== "hello") {
    const changeEvent = yield (
      <div>
        {'Enter "hello":'}:
        <input
          type="text"
          name="hello"
          value={enteredText}
          // Capture the onChange event and return it.
          onChange={capture}
        />
      </div>
    );

    enteredText = changeEvent.target.value;
  }
  yield <div>hello to you to!</div>;
});

Error handling

Hacn also handles errors in component rendering by throwing them into the generator, letting you catch them using the normal javascript try/catch statements:

const ErroringComponent = (props: any) => {
  throw new Error("This component has errors");
};

const Test = hacn(function* () {
  try {
    yield <ErroringComponent />;
  } catch (e) {
    yield <div>An error occurred: {e.message}</div>;
  }
});

The Craziest Example

One problem with generators is that they don't let you jump back to an earlier part of the function execution. We can hack around this by using an obscure feature of javascript loops called 'labels'. These are effectively a restricted form of goto that allow you to break out of inner loops to outer loops.

const CrazyComponent = hacn(function* (props, capture) {
  first: do {
    let event = yield (
      <div>
        <button id="forward" onClick={capture.tag("forward")}>
          Forward
        </button>
      </div>
    );
    second: do {
      let event = yield (
        <div>
          <button id="beginning" onClick={capture.tag("beginning")}>
            Beginning
          </button>
          <button id="back" onClick={capture.tag("back")}>
            Back
          </button>
          <button id="forward" onClick={capture.tag("forward")}>
            Forward
          </button>
        </div>
      );
      if ("back" in event) {
        continue first;
      } else if ("beginning" in event) {
        continue first;
      }
      third: do {
        let event = yield (
          <div>
            <button id="beginning" onClick={capture.tag("beginning")}>
              Beginning
            </button>
            <button id="back" onClick={capture.tag("back")}>
              Back
            </button>
            <button id="forward" onClick={capture.tag("forward")}>
              Forward
            </button>
          </div>
        );
        if ("back" in event) {
          continue second;
        } else if ("beginning" in event) {
          continue first;
        }
        break first;
      } while (true);
    } while (true);
  } while (true);

  return <div id="foo">Done!</div>;
});

This is the craziest example I can come up with and should never actually be used.

Status

This is still a work in progress and there is a lot of stuff still to do, particularly around documentation and performance testing. Any feedback is welcome!

Top comments (0)