John Au-Yeung I'm a web developer interested in JavaScript stuff.

Intro to ForgoJS, an ultra-lightweight UI runtime

5 min read 1538

First Look Forgo JS

Introduction

Forgo is a lightweight (4KB) JavaScript library for creating web apps. Although it’s billed as an alternative to React, and even uses JSX syntax, there are several major differences to keep in mind while working with Forgo.

In this article, we’ll look at how to create client-side web apps with the Forgo library, and along the way, we’ll look at the differences between Forgo and React.

Creating a demo component with Forgo

To get started, we should install webpack and webpack-cli globally by running:

npm i -g webpack webpack-cli

Then, we clone the JavaScript repo into our computer with the following command:

npx degit forgojs/forgo-template-javascript#main my-project

Once we’ve done that, we should jump into our my-project folder and run npm i to install the packages listed in package.json. npm start will run the project we just cloned.

By default, the project runs on port 8080. We can change the port on which we run the project by adding the -- --port=8888 option, like so:

npm start -- --port=8888

The project we cloned comes with a JSX parser and other build tools, so we don’t have to worry about the tooling for our project.

Now we can create a simple component by writing the following in index.js:

import { mount, rerender } from "forgo";

function Timer() {
  let seconds = 0;

  return {
    render(props, args) {
      setTimeout(() => {
        seconds++;
        rerender(args.element);
      }, 1000);

      return <div>{seconds}</div>;
    },
  };
}

We call rerender with args.element to re-render a component. Unlike React, the states are stored in regular variables rather than states, and we render the seconds in the return statement.

Mounting the component

To mount the component, we’d add:

window.addEventListener("load", () => {
  mount(<Timer />, document.getElementById("root"));
});

So, together with the sample component we just wrote to index.js, we have:

import { mount, rerender } from "forgo";

function Timer() {
  let seconds = 0;

  return {
    render(props, args) {
      setTimeout(() => {
        seconds++;
        rerender(args.element);
      }, 1000);

      return <div>{seconds}</div>;
    },
  };
}

window.addEventListener("load", () => {
  mount(<Timer />, document.getElementById("root"));
});

We can replace document.getElementById with the element selector, so we can write:

window.addEventListener("load", () => {
  mount(<Timer />, "#root");
});

Child components and props

As in React, we can create child components and pass them props. To do that, we write:

import { mount } from "forgo";

function Parent() {
  return {
    render() {
      return (
        <div>
          <Greeter name="james" />
          <Greeter name="mary" />
        </div>
      );
    },
  };
}

function Greeter({ name }) {
  return {
    render(props, args) {
      return <div>hi {name}</div>;
    },
  };
}

window.addEventListener("load", () => {
  mount(<Parent />, document.getElementById("root"));
});

The Greeter component is a child of the Parent component. Greeter accepts the name prop, which we add in the return statement to render its value.

We can easily add inputs with Forgo as well. To do that, we’d write:

import { mount } from "forgo";

function Input() {
  const inputRef = {};

  return {
    render() {
      function onClick() {
        const inputElement = inputRef.value;
        alert(inputElement.value);
      }

      return (
        <div>
          <input type="text" ref={inputRef} />
          <button onclick={onClick}>get value</button>
        </div>
      );
    },
  };
}

window.addEventListener("load", () => {
  mount(<Input />, document.getElementById("root"));
});

We create an object variable called inputRef and pass it to the ref prop of the input element. Then, we get the input element object with the inputRef.value property, as we did in the onClick method. Now we can get the value property to get the input value.

Lists and keys

Similar to React, we can render lists by calling map on an array and then returning the components we want to render. For instance, we can write:

import { mount } from "forgo";

function App() {
  return {
    render() {
      const people = [
        { name: "james", id: 1 },
        { name: "mary", id: 2 },
      ];
      return (
        <div>
          {people.map(({ key, name }) => (
            <Child key={key} name={name} /&gt;
          ))}
        </div>
      );
    },
  };
}

function Child() {
  return {
    render({ name }) {
      return <div>hi {name}</div>;
    },
  };
}

window.addEventListener("load", () => {
  mount(<App />, document.getElementById("root"));
});

We have an App component with an array people. We can render the array in the browser by calling map and then returning the Child component with the key and name props.

The key prop allows Forgo to distinguish between the items rendered by assigning them a unique ID. name is a regular prop we get in the Child component and render.

Fetching data in Forgo

We can fetch data with promises, just like we do with React components. However, we can only use the then method to get data — no async/await. If we use async and await, we get a regeneratorRuntime not defined error.

Thus, to get data when a component mounts, we can write something like this:

import { mount, rerender } from "forgo";

function App() {
  let data;

  return {
    render(_, args) {
      if (!data) {
        fetch('https://yesno.wtf/api')
          .then(res => res.json())
          .then(d => {
            data = d;
            rerender(args.element);
          })
        return <p>loading</p>
      }
      return <div>{JSON.stringify(data)}</div>
    },
  };
}

window.addEventListener("load", () => {
  mount(<App />, document.getElementById("root"));
});

data initially has no value, in which case we call fetch to make a GET request for some data. Then, we assign the d parameter to data, which has the response data.



We call rerender with args.element to re-render the component after data is updated, and in the next render cycle, the stringified data value is rendered. Now we should be able to see the data we retrieved from the API.

Events and errors

A Forgo component emits various events throughout its render lifecycle. Let’s take the following component as an example:

import { mount } from "forgo";

function App() {
  return {
    render(props, args) {
      return <div id='hello'>Hello</div>;
    },
    mount(props, args) {
      console.log(`mounted on node with id ${args.element.node.id}`);
    },
    unmount(props, args) {
      console.log("unmounted");
    },
  };
}

window.addEventListener("load", () => {
  mount(<App />, document.getElementById("root"));
});

Our render method returns a div with ID hello, and when a component mounts, we call the mount method. args.element.node.id takes the value of the root element’s id attribute, so its value should be 'hello'.

Therefore, we should see 'mounted on node with id hello' logged. The unmount method runs when the component unmounts, so we should see 'unmounted' when the component is removed from the DOM.

For instance, let’s say we have:

import { mount, rerender } from "forgo";

function App() {
  let showChild = false

  return {
    render(props, args) {
      const onClick = () => {
        showChild = !showChild
        rerender(args.element)
      }

      return (
        <div>
          <button onclick={onClick}>toggle</button>
          {showChild ? <Child /> : undefined}
        </div>
      );
    },
  };
}

function Child() {
  return {
    render(props, args) {
      return <div>child</div>;
    },
    unmount(props, args) {
      console.log("unmounted");
    },
  };
}

window.addEventListener("load", () => {
  mount(<App />, document.getElementById("root"));
});

When we click the toggle button, the Child component will disappear, and we should see 'unmounted'.

We can also choose when to re-render a component by calling rerender manually with the shouldUpdate function. shouldUpdate has parameters newProps and oldProps, which, as their names imply, give us the current and old values of the props, respectively.

Consider the following example:

import { mount, rerender } from "forgo";

function App() {
  let name = 'james'

  return {
    render(props, args) {
      const onClick = () => {
        name = name === 'james' ? 'jane' : 'james'
        rerender(args.element)
      }

      return (
        <div>
          <button onclick={onClick}>toggle</button>
          <Greeter name={name} />
        </div>
      );
    },
  };
}

function Greeter() {
  return {
    render({ name }, args) {
      return <div>hi {name}</div>;
    },
    shouldUpdate(newProps, oldProps) {
      console.log(newProps, oldProps)
      return newProps.name !== oldProps.name;
    },
  };
}

window.addEventListener("load", () => {
  mount(<App />, document.getElementById("root"));
});

The toggle button toggles the value of name, which we pass in as the value of Greeter‘s name prop. So, whenever we click the toggle button, we’ll see the console log update.

Finally, we can handle errors in the object we return with the error method. Errors from child components bubble up to the parent.

So, if we have:

import { mount } from "forgo";

function App() {
  return {
    render() {
      return (
        <div>
          <BadComponent />
        </div>
      );
    },
    error(props, args) {
      return (
        <p>
          {args.error.message}
        </p>
      );
    },
  };
}

function BadComponent() {
  return {
    render() {
      throw new Error("error");
    },
  };
}
window.addEventListener("load", () => {
  mount(<App />, document.getElementById("root"));
});

args.error.message should have the string that we passed into the Error constructor since the error propagates from the child component to the parent.

Conclusion

At just 4KB, Forgo is a tiny alternative to React that allows us to create simple components with ease. Since it uses JSX syntax, it should feel familiar to React developers.

There are some major differences in behavior, however. Re-rendering has to be done manually with the rerender method. Component lifecycles are also quite different, and states are stored as plain variables instead of states. There are also no hooks; instead, we use lifecycle methods to perform various actions during a component’s lifecycle.

The easiest way to get started with Forgo yourself is to install the webpack dev dependencies globally and then clone the starter project with degit.

Are you adding new JS libraries to improve performance or build new features? What if they’re doing the opposite?

There’s no doubt that frontends are getting more complex. As you add new JavaScript libraries and other dependencies to your app, you’ll need more visibility to ensure your users don’t run into unknown issues.

LogRocket is a frontend application monitoring solution that lets you replay JavaScript errors as if they happened in your own browser so you can react to bugs more effectively.

https://logrocket.com/signup/

LogRocket works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app’s performance, reporting metrics like client CPU load, client memory usage, and more.

Build confidently — .

John Au-Yeung I'm a web developer interested in JavaScript stuff.

Leave a Reply