forgojs / forgo

An ultra-light UI runtime
MIT License
322 stars 9 forks source link

forgo

Forgo is a 4KB library that makes it super easy to create modern web apps using JSX (like React).

Unlike React, there are very few framework specific patterns and lingo to learn. Everything you already know about DOM APIs and JavaScript will easily carry over.

We'll be tiny. Always.

All of Forgo's code is in one single TypeScript file. It is a goal of the project to remain within that single file.

Installation

npm install forgo

Starting a Forgo project

The easiest way to get started is with the 'create-forgo-app' utility. This relies on git, so you should have git installed on your machine.

npx create-forgo-app my-project

It supports TypeScript too:

npx create-forgo-app my-project --template typescript

And then to run it:

# Switch to the project directory
cd my-project

# Run!
npm start

# To make a production build
npm run build

A Forgo Component

Forgo components are functions that return a Component instance, which has a render() method that returns JSX. Components hold their state using ordinary variables held in the closure scope of the function (called a Component Constructor).

Forgo likes to keep things simple and explicit. It avoids automatic behavior, prefers basic functions and variables instead of implicit constructs, and tries not to come between you and the DOM.

Here's the smallest Forgo component you can make:

import * as forgo from "forgo";

const HelloWorld = () => {
  return new forgo.Component({
    render() {
      return <p>Hello, world!</p>;
    }
  });
};

When a component is created (either by being mounted onto the DOM when the page loads, or because it was rendered by a component higher up in your app), Forgo calls the Component Constructor to generate the component instance. This is where you can put closure variables to hold the component's state.

Then Forgo calls the component's render() method to generate the HTML that Forgo will show on the page.

After the component's first render, the Constructor won't be called again, but the render() method will be called each time the component (or one of its ancestors) rerenders.

Forgo will pass any props (i.e., HTML attributes from your JSX) to both the Constructor and the render() method.

Here's a bigger example - the component below increments a counter when a button is pressed, and counts how many seconds the component has been alive.

import * as forgo from "forgo";

const ClickCounter = (initialProps) => {
  let seconds = 0; // Just a regular variable, no hooks!
  let clickCounter = 0;

  const component = new forgo.Component({
    // Every component has a render() method, which declares what HTML Forgo
    // needs to generate.
    render(props) {
      const { firstName } = props;
      // You can declare any DOM event handlers you need inside the render()
      // method.
      const onclick = (_event: Event) => {
        // Forgo doesn't know or care how you manage your state. This frees you
        // to use any library or code pattern that suits your situation, not
        // only tools designed to integrate with the framework.
        clickCounter += 1;
        // When you're ready to rerender, just call component.update(). Manual
        // updates mean the framework only does what you tell it to, putting you
        // in control of efficiency and business logic.
        //
        // An optional package, forgo-state, can automate this for simple scenarios.
        component.update();
      };

      // Forgo uses JSX, like React or Solid, to generate HTML declaratively.
      // JSX is a special syntax for JavaScript, which means you can treat it
      // like ordinary code (assign it to variables, type check it, etc.).
      return (
        <div>
          <p>Hello, {firstName}!</p>
          <button type="button" onclick={onclick}>
            The button has been clicked {clickCounter} times in {seconds} seconds
          </button>
        </div>
      );
    }
  });

  // You can add callbacks to react to lifecycle events,
  // like mounting and unmounting
  component.mount(() => {
    const timeout = setTimeout(() => {
      seconds++;
      component.update();
    }, 1000);
    component.unmount(() => clearTimeout(timeout));
  });

  return component;
};

Here's how the API looks when using TypeScript (which is optional):

import * as forgo from "forgo";

// The constructor generic type accepts the shape of your component's props
const HelloWorld = () => {
  return new forgo.Component({
    render({ name }) {
      return <p>Hello, {name}!</p>;
    }
  });
};

If you assign the component to a variable (such as when adding lifecycle event handlers), you'll need to annotate the generic types on both the constructor and the component.

Generic props can also be used:

import * as forgo from "forgo";

// Props have to be assigned to the initial props for TSX to recognize the generic
type ListProps<T extends string | number> = {
  data: T[];
};

const List = <T extends string | number>(initial: ListProps<T>) =>
  new forgo.Component<ListProps<T>>({
    render(props) {
      return (
        <ul>
          {props.data.map((item) => (
            <li>{item}</li>
          ))}
        </ul>
      );
    },
  });

const App = () =>
  new forgo.Component({
    render(props) {
      return <List data={[1, "2", 3]} />;
    },
  });

If you're handy with TypeScript, we'd love a PR to infer the types!

import * as forgo from "forgo";

interface HelloWorldProps {
  name: string;
}
const HelloWorld = () => {
  const component = new forgo.Component<HelloWorldProps>({
    render({ name }) {
      return <p>Hello, {name}!</p>;
    }
  });

  component.mount(() => console.log("Mounted!"));

  return component;
};

Launching your components when the page loads

Use the mount() function once your document has loaded.

import { mount } from "forgo";

// Wait for the page DOM to be ready for changes
function ready(fn) {
  if (document.readyState !== "loading") {
    fn();
  } else {
    document.addEventListener("DOMContentLoaded", fn);
  }
}

ready(() => {
  // Attach your app's root component to a specific DOM element
  mount(<App />, document.getElementById("root"));
});

Instead of retrieving the DOM element yoursely, you could pass a CSS selector and Forgo will find the element for you:

ready(() => {
  mount(<App />, "#root");
});

Child Components and Passing Props

Props and children work just like in React and similar frameworks:

// Component Constructors will receive the props passed the *first* time the
// component renders. But beware! This value won't be updated on later renders.
// Props passod to the Constructor are useful for one-time setup, but to read
// the latest props you'll need to use the value passed to render().
const Parent = (_initialProps) => {
  return new forgo.Component({
    // The props passed here will always be up-to-date.
    //
    // All lifecycle methods (render, mount, etc.) receive a reference to the
    // component. This makes it easy to create reusable logic that works for
    // many different components.
    render(_props, _component) {
      return (
        <div>
          <Greeter firstName="Jeswin" />
          <Greeter firstName="Kai" />
        </div>
      );
    }
  });
};

const Greeter = (_initialProps) => {
  return new forgo.Component({
    render(props, _component) {
      return <div>Hello {props.firstName}</div>;
    }
  });
};

You can pass any kind of value as a prop - not just strings! You just have to use curly braces instead of quotes:

const MyComponent = () => {
  return new forgo.Component({
    render(_props) {
      return <NumberComponent myNumber={2} />;
    }
  });
};

You can have one component wrap JSX provided by another. To do this, just render props.children.

const Parent = () => {
  return new forgo.Component({
    render(_props) {
      return
        <Child>
          <p>Hello, world!</p>
        </Child>
      )
    }
  });
}

const Child = () => {
  return new forgo.Component({
    render(props) {
      return (
        <div>
          <p>Here's what the parent told us to render:</p>
          {props.children}
        </div>
      )
    }
  });
}

Reading Form Input Elements

Forgo encourages you to use the vanilla DOM API when you need to read form field values, by directly accessing the DOM elements in the form.

To access the actual DOM elements corresponding to your markup (and the values contained within them), you need to use the ref attribute in the JSX markup of the element you want to reference. An element referenced by the ref attribute will have its 'value' property set to the actual DOM element when it gets created.

Here's an example:

const MyComponent = (_initialProps) => {
  // This starts as an empty object. After the element is created, this object
  // will have a `value` field holding the element.
  const myInputRef = {};

  return new forgo.Component({
    render(_props, _component) {
      const onClick = () => {
        const inputElement = myInputRef.value;
        alert(inputElement.value); // Read the text input.
      };

      return (
        <div>
          <input type="text" ref={myInputRef} />
          <button type="button" onclick={onClick}>Click me!</button>
        </div>
      );
    }
  });
};

If you want, you can bypass Forgo entirely when reading form field values. If you set the id field on the form field, then you could use the vanilla DOM API to access that element directly:

const onClick = () => {
  const inputElement = document.getElementById("my-input");
  alert(inputElement.value);
};

Lastly, DOM events like key presses and clicks pass the affected element to the event handler as event.target:

const Component = (_initialProps) => {
  return new forgo.Component({
    render(_props, _component) {
      const onInput = (event) => {
        alert(event.target.value);
      };

      return (
        <div>
          <input type="text" oninput={onInput} />
        </div>
      );
    }
  });
};

Rendering Lists and using Keys

Forgo will render any arrays it sees in the JSX. To create a list of elements, just use the array's myArray.map() method to generate JSX for each item in the array.

Each item in the array may be given a key attribute. Keys help Forgo identify which items in a list have changed, are added, or are removed. While Forgo works well without keys, it is a good idea to add them since it lets Forgo be more efficient by only mounting or unmounting components that actually need it.

You can use any data type for a key strings, numbers or even objects. The key values only need to be unique. Forgo compares keys using === (reference equality), so be careful when using mutable objects as keys.

When looping over an array, don't use the array index as a key - keys should be something tied to the specific value being rendered (like a permanent ID field). The same array index might be associated with different values if you reorder the array, and so using the array index as a key will cause unexpected behavior.

const Parent = () => {
  return new forgo.Component({
    render(_props, _component) {
      const people = [
        { firstName: "jeswin", id: 123 },
        { firstName: "kai", id: 456 },
      ];
      return (
        <div>
          {people.map((item) => (
            <Child key={item.id} firstName={item.firstName} />
          ))}
        </div>
      );
    }
  });
};

const Child = (initialProps) => {
  return new forgo.Component({
    render(props) {
      return <div>Hello {props.firstName}</div>;
    },
  });
};

Fetching data asynchronously

Your component might need to load data asynchronously (such as making a network request). Here's how to do that:

export const InboxComponent = (_initialProps) => {
  // This will be empty at first, and will get filled in sometime after the
  // component first mounts.
  let messages = undefined;

  const component = new forgo.Component({
    render(_props, _component) {
      // Messages are empty. Let's fetch them.
      if (!messages) {
        return <p>Loading data...</p>;
      }

      // After messages are fetched, the component will rerender and now we can
      // show the data.
      return (
        <div>
          <header>Your Inbox</header>
          <ul>
            {messages.map((message) => (
              <li>{message}</li>
            ))}
          </ul>
        </div>
      );
    }
  });

  component.mount(async () => {
    messages = await fetchMessagesFromServer();
    component.update();
  });

  return component;
};

The Mount Event

The mount event is fired just once per component, when the component has just been created. This is useful for set-up logic like starting a timer, fetching data, or opening a WebSocket.

You can register multiple mount callbacks, which is useful if you want to have reusable logic that you apply to a number of components.

const Greeter = (_initialProps) => {
  const component = new forgo.Component({
    render(_props, _component) {
      return <div id="hello">Hello {props.firstName}</div>;
    }
  });

  component.mount((_props, _component) => {
    console.log("The component has been mounted.");
  });

  return component;
};

The Unmount Event

A component is unmounted when your app no longer renders it (such as when a parent component chooses to display something different, or when an item is removed from a list you're rendering).

When a component is unmounted, you might want to do tear-down, like canceling a timer or closing a WebSocket. To do this, you can register unmount callbacks on your component, which will be called when the component is unmounted.

The callbacks are passed the current props and the component instance, just like the render() method.

const Greeter = (_initialProps) => {
  const component = new forgo.Component({
    render(props, _component) {
      return <div>Hello {props.firstName}</div>;
    }
  });

  component.unmount((props, _component) => {
    console.log("The component has been unloaded.");
  });

  return component;
};

Skipping renders

Sometimes you have a reason why a component shouldn't be rendered right now. For example, if you're using immutable data structures, you may want to only rerender if the data structure has changed.

Forgo components accept shouldUpdate callbacks, which return true/false to signal whether the component should / should not be rerendered. If any shouldUpdate callbacks return true, the component will be rerendered. If they all return false (or if none are registered), the component's render() method won't be called, skipping all DOM operations for the component and its decendants.

The callbacks receive the new props for the proposed render, and the old props used in the last render.

Using shouldUpdate is completely optional, and typically isn't necessary.

const Greeter = (_initialProps) => {
  const component = new forgo.Component({
    render(props, component) {
      return <div>Hello {props.firstName}</div>;
    }
  });

  component.shouldUpdate((newProps, oldProps) => {
    return newProps.firstName !== oldProps.firstName;
  });

  return component;
}

Error handling

Forgo lets components define an error() method, which is run any time the component (or any of its decendants) throws an exception while running the component's render() method. The error method can return JSX that is rendered in place of the render output, to display an error message to the user.

If no ancestors have an error() method registered, the render will abort and Forgo will print an error to the console.

// Here's a component which throws an error.
const BadComponent = () => {
  return new forgo.Component({
    render() {
      throw new Error("Some error occurred :(");
    }
  });
}

// The first ancestor with an error() method defined will catch the error
const Parent = (initialProps) => {
  return new forgo.Component({
    render() {
      return (
        <div>
          <BadComponent />
        </div>
      );
    },
    error(props, error, _component) {
      return (
        <p>
          Error in {props.name}: {error.message}
        </p>
      );
    }
  });
}

The AfterRender Event

If you're an application developer you'll rarely need to use this - it's provided for building libraries that wrap Forgo.

The afterRender event runs after render() has been called and the rendered elements have been created in the DOM. The callback is passed the previous DOM element the component was attached to, if it changed in the latest render.

const Greeter = (_initialProps) => {
  const component = new forgo.Component({
    render(props, component) {
      return <div id="hello">Hello {props.firstName}</div>;
    }
  });

  component.afterRender((_props, previousNode, _component) => {
    console.log(
      `This component is mounted on ${component.__internal.element.node.id}, and was previously mounted on ${previousNode.id}`
    );
  });

  return component;
};

Passing new props when rerendering

The most straight forward way to do rerender is by invoking it with component.update(), as follows:

const TodoList = (initialProps) => {
  let todos = [];

  return new forgo.Component({
    render(props, component) {
      const addTodos = (text) => {
        todos.push(text);
        component.update();
      };
      return (
        <button type="button" onclick={addTodos}>
          Add a Todo
        </button>
      );
    }
  });
}

component.update() may optionally receive new props to use in the render. Omitting the props parameter will rerender leave the props unchanged.

const newProps = { name: "Kai" };
component.update(newProps);

Rendering without mounting

Forgo also exports a render method that returns the rendered DOM node that could then be manually mounted.

import { render } from "forgo";

const { node } = render(<Component />);

window.addEventListener("load", () => {
  document.getElementById("root").firstElementChild.replaceWith(node);
});

Routing

Forgo offers an optional package (forgo-router) for handling client-side navigation. Forgo Router is just around 1KB gzipped. Read more at https://github.com/forgojs/forgo-router

Here's an example:

import { Router, Link, matchExactUrl, matchUrl } from "forgo-router";

const App = () => {
  return new forgo.Component({
    render() {
      return (
        <Router>
          <Link href="https://github.com/forgojs/forgo/blob/main/">Go to Home Page</Link>
          {matchExactUrl("/", () => <Home />) ||
            matchUrl("/customers", () => <Customers />) ||
            matchUrl("/about", () => <AboutPage />)}
        </Router>
      );
    }
  });
}

Application State Management

Forgo offers an optional package (forgo-state) with an easy-to-use application state management solution for Forgo. This solves a similar problem to Redux or MobX. It's than 1KB gzipped. Read more at https://github.com/forgojs/forgo-state

Here's an example:

import { bindToStates, defineState } from "forgo-state";

// Define one (or more) application state containers.
const mailboxState = defineState({
  username: "Bender B. Rodriguez",
  messages: [],
  drafts: [],
  spam: [],
  unread: 0
});

// A Forgo component that should react to state changes
const MailboxView = () => {
  const component = new forgo.Component({
    render() {
      if (mailboxState.messages.length > 0) {
        return (
          <div>
            {mailboxState.messages.map((m) => <p>{m}</p>)}
          </div>
        );
      }

      return (
        <div>
          <p>There are no messages for {mailboxState.username}.</p>
        </div>
      );
    }
  });

  component.mount(() => updateInbox());

  // MailboxView must change whenever mailboxState changes.
  //
  // Under the hood, this registers component.mount() and component.unmount()
  // even handlers
  bindToStates([mailboxState], component);
  return component;
}

async function updateInbox() {
  const data = await fetchInboxData();
  // The next line causes a rerender of the MailboxView component
  mailboxState.messages = data;
}

Lazy Loading

If you want to lazy load a component, you can use the community-provided forgo-lazy package. This is useful for code splitting, where you want the initial page load to be quick (loading the smallest JS possible), and then load in more components only when the user needs them. Read more at https://github.com/jacob-ebey/forgo-lazy

It's works like this:

import lazy, { Suspense } from "forgo-lazy";

const LazyComponent = lazy(() => import("./lazy-component"));

const App = () => {
  return new forgo.Component({
    render() {
      return (
        <Suspense fallback={() => "Loading..."}>
          <LazyComponent title="It's that easy :D" />
        </Suspense>
      );
    }
  });
}

Integrating Forgo into an existing app

Forgo can be integrated into an existing web app written with other frameworks (React, Vue, etc.), or with lower-level libraries like jQuery.

To help with that, the forgo-powertoys package (less than 1KB in size) exposes a rerenderElement() function which can receive a CSS selector and rerender the Forgo component associated with that element. This works from outside the Forgo app, so you can drive Forgo components using your framework/library of choice. Read more at https://github.com/forgojs/forgo-powertoys

Here's an example:

import { rerenderElement } from "forgo-powertoys";

// A forgo component.
const LiveScores = () => {
  return new forgo.Component({
    render(props) {
      return <p id="live-scores">Top score is {props.topscore}</p>;
    }
  });
}

// Mount it on a DOM node usual
window.addEventListener("load", () => {
  mount(<SimpleTimer />, document.getElementById("root"));
});

// Now you can rerender the component from anywhere, anytime! Pass in the ID of
// the root element the component returns, as well as new props.
rerenderElement("#live-scores", { topscore: 244 });

Server-side Rendering (SSR)

From Node.js you can render components to an HTML string with the forgo-ssr package. This allows you to prerender components on the server, from server-side frameworks like Koa, Express etc. Read more at https://github.com/forgojs/forgo-ssr

Here's an example:

import render from "forgo-ssr";

// A forgo component.
const MyComponent = () => {
  return new forgo.Component({
    render() {
      return <div>Hello world</div>;
    }
  });
}

// Get the html (string) and serve it via koa, express etc.
const html = render(<MyComponent />);

Manually adding elements to the DOM

Forgo allows you to use the built-in browser DOM API to insert elements into the DOM tree rendered by a Forgo component. Forgo will ignore these elements. This is useful for working with charting libraries, such as D3.

If you add unmanaged nodes as siblings to nodes which Forgo manages, Forgo pushes the unmanaged nodes towards the bottom of the sibling list when managed nodes are added and removed. If you don't add/remove managed nodes, the unmanaged nodes will stay in their original positions.

ApexCharts example

Code Sandbox for this example

const App = () => {
  const chartElement = {};

  const component = new forgo.Component({
    render(_props, component) {
      const now = new Date();
      return (
        <div>
          <p>
            This component continually rerenders. Forgo manages the timestamp,
            but delegates control of the chart to ApexCharts.
          </p>
          <div ref={chartElement}></div>
          <p>
            The current time is:{" "}
            <time datetime={now.toISOString()}>{now.toLocaleString()}</time>
          </p>
        </div>
      );
    }
  });

  component.mount(() => {
    const chartOptions = {
      chart: {
        type: "line",
      },
      series: [
        {
          name: "sales",
          data: [30, 40, 35, 50, 49, 60, 70, 91, 125],
        },
      ],
      xaxis: {
        categories: [1991, 1992, 1993, 1994, 1995, 1996, 1997, 1998, 1999],
      },
    };

    const chart = new ApexCharts(chartElement.value, chartOptions);

    chart.render();

    const interval = setInterval(() => component.update(), 1_000);
    component.unmount(() => clearInterval(interval));
  });

  return component;
};

Try it out on CodeSandbox

You can try the Todo List app with Forgo on CodeSandbox.

Or if you prefer TypeScript, try Forgo TodoList in TypeScript.

There is also an example for using Forgo with forgo-router.

Building

Most users should use create-forgo-app to create the project skeleton - in which case all of this is already set up for you. This is the easiest way to get started.

If you want to stand up a project manually, we'll cover webpack-specific configuration here. Other bundlers would need similar configuration.

esbuild-loader with JavaScript/JSX

Add these lines to webpack.config.js:

module.exports = {
  // remaining config omitted for brevity.
  module: {
    rules: [
      {
        test: /\.(js|jsx)$/,
        exclude: /node_modules/,
        loader: "esbuild-loader",
        options: {
          loader: "jsx",
          target: "es2015",
          jsxFactory: "forgo.createElement",
          jsxFragment: "forgo.Fragment",
        },
      },
    ],
  },
};

esbuild-loader with TypeScript/TSX

Add these lines to webpack.config.js:

module.exports = {
  // remaining config omitted for brevity.
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        exclude: /node_modules/,
        loader: "esbuild-loader",
        options: {
          loader: "tsx",
          target: "es2015",
          jsxFactory: "forgo.createElement",
          jsxFragment: "forgo.Fragment",
        },
      },
    ],
  },
};

While using TypeScript, also add the following lines to your tsconfig.json. This lets you do tsc --noEmit for type checking, which esbuild-loader doesn't do.

Add these lines to tsconfig.json:

{
  "compilerOptions": {
    "jsx": "react",
    "jsxFactory": "forgo.createElement",
    "jsxFragmentFactory": "forgo.Fragment"
  }
}

babel-loader with JSX

This is slower than esbuild-loader, so use only as needed.

Add these lines to webpack.config.js:

module.exports = {
  // remaining config omitted for brevity.
  module: {
    rules: [
      {
        test: /\.(js|jsx)$/,
        exclude: /node_modules/,
        use: ["babel-loader"],
      },
    ],
  },
};

Add these lines to babel.config.json:

{
  "presets": ["@babel/preset-env", "@babel/preset-react"],
  "plugins": [
    ["@babel/plugin-transform-react-jsx", { "pragma": "forgo.createElement" }]
  ]
}

TSX with ts-loader

Add these lines to webpack.config.js:

module.exports = {
  // remaining config omitted for brevity.
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        use: "ts-loader",
        exclude: /node_modules/,
      },
    ],
  },
};

Add these lines to tsconfig.json:

{
  "compilerOptions": {
    "jsx": "react",
    "jsxFactory": "forgo.createElement",
    "jsxFragmentFactory": "forgo.Fragment"
  }
}

Core Team

Getting Help

If you find issues, please file a bug on Github. You can also reach out to us via Twitter (@forgojs).

Deprecation of legacy component syntax is 3.2.0

In version 3.2.0, Forgo introduced a new syntax for components. This change makes Forgo easier to extend with reusable libraries, and makes it straightforward to colocate logic that spans mounts & unmounts.

The legacy component syntax will be removed in v4.0. Until then, Forgo will print a warning to the console whenever it sees a legacy component. You can suppress these warnings by setting window.FORGO_NO_LEGACY_WARN = true;.

Migrating

Forgo components are now instances of the Component class, rather than freestanding object values. The new Component constructor accepts an object holding a render() an optional error() method. All other methods have been converted to lifecycle methods on the component instance. You may register multiple handlers for each lifecycle event, and you may register new handlers from inside a handler (e.g., a mount handler that registers its own unmount logic).

args has been replaced by a reference to the component instance, in all lifecycle event handlers. This simplifies writing reusable component logic.

The error() method now receives the error object as a function parameter, rather than as a property on args.

The afterRender lifecycle event now receives the previousNode as a function parameter, instead of a property on args.

Before:

const MyComponent = () => {
  return {
    render() {},
    error() {},
    mount() {},
    unmount() {},
    shouldUpdate() {},
    afterRender() {},
  };
}

After:

const MyComponent = () => {
  const component = new Component({
    render() {},
    error() {}
  });

  component.mount(() => {});
  component.unmount(() => {});
  component.shouldUpdate(() => {});
  component.afterRender(() => {});

  return component;
}

Breaking changes in 2.0

Forgo 2.0 drops support for the new JSX transform introduced via "jsx-runtime". This never worked with esbuild loader, and more importantly doesn't play well with ES modules. If you were using this previously, switch to the configurations discussed above.