• Stars
    star
    777
  • Rank 58,500 (Top 2 %)
  • Language
    JavaScript
  • License
    ISC License
  • Created about 7 years ago
  • Updated about 6 years ago

Reviews

There are no reviews yet. Be the first to send feedback to the community and the maintainers!

Repository Details

◘ A tiny view + state management solution using innerHTML

innerself

Build Status

A tiny view + state management solution using innerHTML.

innerHTML is fast. It's not fast enough if you're a Fortune 500 company or even if your app has more than just a handful of views. But it might be just fast enough for you if you care about code size.

I wrote innerself because I needed to make sense of the UI for a game I wrote for the js13kGames jam. The whole game had to fit into 13KB. I needed something extremely small which would not make me lose sanity. innerself clocks in at under 50 lines of code. That's around 600 bytes minified, ~350 gzipped.

innerself is inspired by React and Redux. It offers the following familiar concepts:

  • composable components,
  • a single store,
  • a dispatch function,
  • reducers,
  • and even an optional logging middleware for debugging!

It does all of this by serializing your component tree to a string and assigning it to innerHTML of a root element. It even imitates Virtual DOM diffing by comparing last known output of components with the new one :) I know this sounds like I'm crazy but it actually works quite nice for small and simple UIs.

If you don't care about size constraints, innerself might not be for you. Real frameworks like React have much more to offer, don’t sacrifice safety, accessibility, nor performance, and you probably won’t notice their size footprint.

innerself was a fun weekend project for me. Let me know what you think!

Caveats

You need to know a few things before you jump right in. innerself is a less-than-serious pet project and I don't recommend using it in production.

It's a poor choice for form-heavy UIs. It tries to avoid unnecessary re-renders, but they still happen if the DOM needs even a tiniest update. Your form elements will keep losing focus because every re-render is essentially a new assignment to the root element's innerHTML.

When dealing with user input in serious scenarios, any use of innerHTML requires sanitization. innerself doesn't do anything to protect you or your users from XSS attacks. If you allow keyboard input or display data fetched from a database, please take special care to secure your app. The innerself/sanitize module provides a rudimentary sanitization function.

Perhaps the best use-case for innerself are simple mouse-only UIs with no keyboard input at all :)

Showcase

Install

$ npm install innerself

For a more structured approach @bsouthga created innerself-app. Use it to bootstrap new innerself apps from a predefined template.

Usage

innerself expects you to build a serialized version of your DOM which will then be assigned to innerHTML of a root element. The html helper allows you to easily interpolate Arrays.

import html from "innerself";
import ActiveTask from "./ActiveTask";

export default function ActiveList(tasks) {
    return html`
        <h2>My Active Tasks</h2>
        <ul>
            ${tasks.map(ActiveTask)}
        </ul>
    `;
}

The state of your app lives in a store, which you create by passing the reducer function to createStore:

const { attach, connect, dispatch } = createStore(reducer);
window.dispatch = dispatch;
export { attach, connect };

You need to make dispatch available globally in one way or another. You can rename it, namespace it or put it on a DOM Element. The reason why it needs to be global is that the entire structure of your app must be serializable to string at all times. This includes event handlers, too.

import html from "innerself";

export default function ActiveTask(text, index) {
    return html`
        <li>
            ${text} ${index}
            <button
                onclick="dispatch('COMPLETE_TASK', ${index})">
                Mark As Done</button>
        </li>
    `;
}

You can put any JavaScript into the on<event> attributes. The browser will wrap it in a function which takes the event as the first argument (in most cases) and in which this refers to the DOM Element on which the event has been registered.

The dispatch function takes an action name and a variable number of arguments. They are passed to the reducer which should return a new version of the state.

const init = {
    tasks: [],
    archive: []
};

export default function reducer(state = init, action, args) {
    switch (action) {
        case "ADD_TASK": {
            const {tasks} = state;
            const [value] = args;
            return Object.assign({}, state, {
                tasks: [...tasks, value],
            });
        }
        case "COMPLETE_TASK": {
            const {tasks, archive} = state;
            const [index] = args;
            const task = tasks[index];
            return Object.assign({}, state, {
                tasks: [
                    ...tasks.slice(0, index),
                    ...tasks.slice(index + 1)
                ],
                archive: [...archive, task]
            });
        }
        default:
            return state;
    }
}

If you need side-effects, you have three choices:

  • Put them right in the on<event> attributes.
  • Expose global action creators.
  • Put them in the reducer. (This is considered a bad practice in Redux because it makes the reducer unpredictable and harder to test.)

The dispatch function will also re-render the entire top-level component if the state changes require it. In order to be able to do so, it needs to know where in the DOM to put the innerHTML the top-level component generated. This is what attach returned by createStore is for:

import { attach } from "./store";
import App from "./App";

attach(App, document.querySelector("#root"));

createStore also returns a connect function. Use it to avoid passing data from top-level components down to its children where it makes sense. In the first snippet above, ActiveList receives a tasks argument which must be passed by the top-level component.

Instead you can do this:

import html from "innerself";
import { connect } from "./store";
import ActiveTask from "./ActiveTask";
import TaskInput from "./TaskInput";

function ActiveList(state) {
    const { tasks } = state;
    return html`
        <h2>My Active Tasks</h2>
        <ul>
            ${tasks.map(ActiveTask)}
            <li>
                ${TaskInput()}
            </li>
        </ul>
    `;
}

export default connect(ActiveList);

You can then avoid passing the state explicitly in the top-level component:

import html from "innerself";
import { connect } from "./store";

import ActiveList from "./ActiveList";
import ArchivedList from "./ArchivedList";

export default function App(tasks) {
    return html`
        ${ActiveList()}
        ${ArchivedList()}
    `;
}

Connected components always receive the current state as their first argument, and then any other arguments passed explicitly by the parent.

Logging Middleware

innerself comes with an optional helper middleware which prints state changes to the console. To use it, simply decorate your reducer with the default export of the innerself/logger module:

import { createStore } from "innerself";
import withLogger from "innerself/logger";
import reducer from "./reducer"

const { attach, connect, dispatch } =
    createStore(withLogger(reducer));

Crazy, huh?

I know, I know. But it works! Check out the examples:

  • example01 - an obligatory Todo App.
  • example02 by @flynnham.
  • example03 illustrates limitations of innerself when dealing with text inputs and how to work around them.

How It Works

The update cycle starts with the dispatch function which passes the action to the reducer and updates the state.

When the state changes, the store compares the entire string output of top-level components (the ones attached to a root element in the DOM) with the output they produced last. This means that most of the time, even a slightest change in output will re-render the entire root.

It's possible to dispatch actions which change the state and don't trigger re-renders. For instance in example01 the text input dispatches CHANGE_INPUT actions on keyup events. The current value of the input is then saved in the store. Crucially, this value is not used by the TaskInput component to populate the input element. The whole thing relies on the fact that the native HTML input element stores its own state when the user is typing into it.

This limitation was fine for my use-case but it's worth pointing out that it badly hurts accessibility. Any change to the state which causes a re-render will make the currently focused element lose focus.

React is of course much smarter: the Virtual DOM is a lightweight representation of the render tree and updates to components produce an actual diff. React maps the items in the Virtual DOM to the elements in the real DOM and is able to only update what has really changed, regardless of its position in the tree.

Here's an interesting piece of trivia that I learned about while working on this project. React only re-renders components when their local state changes, as signaled by this.setState(). The fact that it also looks like components re-render when their props change derives from that as well. Something needs to pass those props in, after all, and this something is the parent component which first needs to decide to re-render itself.

When you think about how you can connect components with react-redux to avoid passing state to them from parents it becomes clear why behind the scenes it calls this.setState(dummyState) (which is an empty object) to trigger a re-render of the connected component :) It does this only when the sub-state as described by the selector (mapStateToProps) changes, which is easy to compute (and fast) if the reducers use immutability right. In the best case scenario it only needs to compare the identity of the sub-state to know that it's changed.

More Repositories

1

rowerynka

Rowerynka to bot dla Facebookowego Messengera, który znajduje najbliższe wolne rowery Veturilo.
JavaScript
12
star
2

cityninja

Nie daj zaskoczyć się utrudnieniom komunikacji miejskiej.
JavaScript
11
star
3

homm1k

A homage to Heroes of Might and Magic for JS1K 2019
JavaScript
11
star
4

havefun

Have Fun Audio Editor
TypeScript
8
star
5

preso

Support for presentations I've given
JavaScript
7
star
6

todo

A todo/progress tracking app for Mozilla's localization dashboard (http://hg.mozilla.org/l10n/django-site/).
Python
5
star
7

detach-tabs

A WebExtension for moving multiple tabs into a new window.
JavaScript
5
star
8

raptor-compare

Compare sets of Raptor results and test for statistical significance of the observed difference.
JavaScript
4
star
9

webi18n

A set of libraries allowing the localization of your web app
PHP
3
star
10

breakoutc

A Breakout clone written in C using a strict ECS architecture
C
2
star
11

timeline

Projects timeline
JavaScript
2
star
12

distancing

See how social distancing works
TypeScript
2
star
13

git-attach

git extension to attach patches to Bugzilla
JavaScript
2
star
14

l20n-tinker

Online editors for various L20n syntax proposals
JavaScript
2
star
15

l20n-syntax-experiments

Playground for experimenting with the L20n syntax
Clojure
2
star
16

pewpew

A simulation for benchmarking Goodluck ECS.
TypeScript
2
star
17

akupunktura

Share the 'pressure points' in your city. According to Jaime Lerner, the former mayor of Curitiba, Brazil, by focusing on certain "pressure points" in urban areas, a city can dramatically increase its quality of life. This concept is called 'urban acupuncture' and the app is an implementation of it for a web-enabled city.
Python
2
star
18

wyspa

A tiny one-way binding library with simple virtual DOM
JavaScript
2
star
19

stet

A git-powered weblog
Python
1
star
20

rogues

A multiplayer roguelike written in node.js
JavaScript
1
star
21

fluent-react-for-gecko

An example of GeckoLocalizationProvider
JavaScript
1
star
22

fake-canvas

A native module mocking a subset of the RenderingContext2D API
C++
1
star
23

responsive-l20n-app

An example webapp using L20n's responsive features
JavaScript
1
star
24

WarsawAppDay

Support for the introduction talk at Firefox OS App Day, Warsaw 2013
JavaScript
1
star
25

langpack-submitter

Collection of tools to submit langpacks to the Marketplace
Shell
1
star
26

patcamp.pl

The django app powering patcamp.pl
Python
1
star
27

razemdlakamila.pl

HTML
1
star
28

tldr

A collection of excerpts, summaries, memorable quotes and random thoughts and comments on things I read on the Web.
1
star
29

replaydog

Simple StarCraft replay sharing
Python
1
star
30

ReadWriteWiki

PHP
1
star
31

matmul.wasm

A matrix multiplication benchmark for WASM
C
1
star
32

krasula

IRC bot for #aviarypl
JavaScript
1
star
33

get-more-languages

A simple app for installing langpacks.
JavaScript
1
star
34

ogs

Support for my session at onGameStart 2012: http://ongamestart.com/#stas
JavaScript
1
star
35

berek

Unfinished 2D platformer
JavaScript
1
star
36

l20n-react-experiments

JavaScript
1
star