Skip to content

Minimal reactive JSX UI builder designed to be highly ergonomic with MVVM

License

Notifications You must be signed in to change notification settings

colelawrence/jsx-view

Repository files navigation

jsx-view

A simple HTML DOM JSX renderer with RxJS

MIT License Twitter

Write your web ui with battle-tested RxJS for granular updates.

This is one of my favorite libraries, and I use it for several projects I maintain, including some work from storyai and a new product I'm actively working on. If you like what you see here, please reach out to me at cole @ [github user name] .com and I'd be happy to answer questions.

Great for:

  • Business Logic Components [BLoC]
  • Model-View-ViewModel [MVVM]

Features

  • No DOM diffing and no "lifecycle loop". Only Observables which get subscribed to and directly update the DOM elements.
  • Minimal JSX wiring up with full type definitions for all common HTMLElement attributes.
  • Any attribute accepts an Observable of its value, and this is type checked.
  • An Observable of any JSX.Child (string, null, JSX.Element, etc), can be used as a JSX.Child.
  • Adds special props: is, $style, $class, ref, and tags.
  • exports declaration maps (go-to-def goes to TypeScript source code)

Creating your first component

function MyComponent(props: { title: JSX.Child, children: JSX.Children }) {
  return <div>
    <h3>{props.title}</h3>
    {props.children}
  </div>
}


<MyComponent title="Hello">
  <p>content</p>
</MyComponent>


<MyComponent
  title={<span>Hello <b>JSX-View</b></span>}
  children={<p>content</p>}
/>

// `JSX.Child` includes `string`
const $inputValue$ = new BehaviorSubject("Hello, JSX View!")
const usage3 = <MyComponent
  // You can embed Observable<string> (or any Observable<JSX.Child>)
  // in between any tags
  title={<span>Hello <b>{$inputValue$}</b></span>}
  // You can also just use Observable<string> as a JSX.Child value
  // title={$inputValue$}
  children={[
    <label for="your-title">Title</label>,
    // Binding
    <input
      id="your-title" 
      value={$inputValue$}
      onchange={evt => 
        $inputValue$.next((evt.target as HTMLInputElement).value)
      }
    />,
  ]}
/>

Todo App example

This was adapted from a similar demo I put together with React + RxJS, so if tehre's something missing or misspelled, please accept my apologies.

// TodoView.tsx
import { useContext, createContext, renderSpec } from "jsx-view"
import type { Subscription } from "rxjs"
import { map } from "rxjs/operators"
import createTodoState, { Todo } from "./TodoState"

const todos: Todo[] = [
  createTodo("Build UI for TodoApp", true),
  createTodo("Toggling a Todo"),
  createTodo("Deleting a Todo"),
  createTodo("Performant lists", true),
  createTodo("Adding a Todo"),
]

export default function mountApp(parentSub: Subscription, container: HTMLElement) {
  const element = renderSpec(parentSub, <TodoApp />)
  container.appendChild(element)
  parentSub.add(() => container.removeChild(element))
}

const TodoState = createContext(createTodoState(todos))

function TodoApp() {
  const state = useContext(TodoState)

  return (
    <div class="container">
      <h1>
        Todos <small style={{ fontSize: "16px" }}>APP</small>
      </h1>
      {/* Create an observable of a single element and drop it right in. */}
      {state.todos$.pipe(
        map((todosArr) => (
          <ul class="list-group">
            {todosArr.map((todo) => (
              <TodoItem todo={todo} />
            ))}
          </ul>
        )),
      )}
      <br />
      <form class="form-group" onsubmit={preventDefaultThen(state.addTodo)}>
        <label for="todo-title">New Todo Title</label>
        <div class="input-group">
          <input
            id="todo-title"
            type="text"
            class="form-control"
            // Assign any observable to any attribute when the
            // observable emits, the only work that happens is
            // a direct assignment to the attribute on the HTML
            // element.
            value={state.todoInput$}
            onchange={changeValue(state.updateNewTodoInput)}
            placeholder="What do you want to get done?"
          />
          <button class="btn btn-primary">Add</button>
        </div>
      </form>
    </div>
  )
}

/** Todo Item appears within {@link TodoApp} */
function TodoItem({ todo }: { todo: Todo }) {
  const state = useContext(TodoState)

  return (
    <li
      class="list-group-item"
      {...onEnterOrClick(() => {
        state.toggleTodo(todo.id)
      })}
    >
      <span style={{ textDecoration: todo.done ? "line-through" : "none" }}>{todo.title}</span>
      <button
        class="btn btn-sm btn-default float-right"
        aria-label={`Delete "${todo.title}"`}
        {...onEnterOrClick(() => {
          state.deleteTodo(todo.id)
        })}
      >
        πŸ—‘
      </button>
    </li>
  )
}
/**
 * Helper for creating `onchange` listeners
 * @example
 * <input onchange={changeValue(state.updateValue)} value={state.value$}/>
 */
export function changeValue(handler: (value: string) => any) {
  return function (this: HTMLFormElement | HTMLInputElement, _evt: ChangeEvent) {
    handler(this.value)
  }
}

/**
 * Helper for canceling default behaviors in functions
 * @example
 * <form
 *   onsubmit={preventDefaultThen(() => console.log('prevented default submit'))}
 * >
 *   ...
 *   <button>Submit</button>
 * </form>
 */
export function preventDefaultThen(handler: () => void) {
  return (evt: { preventDefault: Function }) => {
    evt.preventDefault()
    handler()
  }
}

/**
 * Helper for responding to enter key and click events.
 * This produces a set of properties that you must spread.
 *
 * Props:
 *  * `tabindex` for making the element tabbable
 *  * `onclick`
 *  * `onkeydown` for detecting enter key pressed on the element
 *
 * Example:
 * ```jsx
 *   <li {...onEnterOrClick(() => console.log('activated Item 1'))}>Item 1</li>
 * ```
 */
export function onEnterOrClick(handler: () => void): JSX.HtmlProps {
  return {
    tabindex: "0",
    onclick: (evt) => {
      evt.stopPropagation()
      handler()
    },
    onkeydown: (evt) => {
      if (evt.key === "Enter") {
        evt.stopPropagation()
        if (!(evt.currentTarget instanceof HTMLButtonElement || evt.currentTarget instanceof HTMLAnchorElement)) {
          // onClick will handle this one
          handler()
        }
      }
    },
  }
}

function createTodo(title: string, done = false): Todo {
  return {
    id: Math.random(),
    title,
    done,
  }
}
// TodoState.ts
import { BehaviorSubject } from "rxjs"

export type Todo = {
  id: number
  done: boolean
  title: string
}

export default function createTodoState(initialTodos: Todo[] = []) {
  const $todos$ = new BehaviorSubject(initialTodos)
  const $todoInput$ = new BehaviorSubject("")

  return {
    todos$: $todos$.asObservable(),
    todoInput$: $todoInput$.asObservable(),
    updateNewTodoInput(value: string) {
      debug("updateNewTodoInput", value)
      $todoInput$.next(value)
    },
    toggleTodo(id: number) {
      debug("toggleTodo", id)
      $todos$.next(
        $todos$.value.map((todo) =>
          todo.id === id
            ? // toggle
              { ...todo, done: !todo.done }
            : // don't update
              todo,
        ),
      )
    },
    deleteTodo(id: number) {
      debug("deleteTodo", id)
      $todos$.next($todos$.value.filter((todo) => todo.id !== id))
    },
    addTodo() {
      if ($todoInput$.value) {
        debug("addTodo", $todoInput$.value)
        $todos$.next([
          ...$todos$.value,
          {
            id: Math.random(),
            done: false,
            title: $todoInput$.value,
          },
        ])
        $todoInput$.next("")
      }
    },
  }
}

const debug = console.log.bind(console, "%cTodoState", "color: dodgerblue")

Setting up your tsconfig.json or jsconfig.json

{
  "compilerOptions": {
    "lib": ["DOM"],

    "jsx": "react-jsx",
    // Alternatively, use `addJSXDev(fn)` handler with source locations with
    // "jsx": "react-jsxdev",

    "jsxImportSource": "jsx-view",
  }
}

Setting up with babel

{
  "plugins": [
    [
      "@babel/plugin-transform-react-jsx",
      {
        "runtime": "automatic", // defaults to classic
        "importSource": "jsx-view" // defaults to react
      }
    ]
  ]
}

Setting up with vite

// vite.config.js or vite.config.ts
import * as path from "path";
import { defineConfig } from "vite";

export default defineConfig({
  // ...

  esbuild: {
    jsx: "automatic",
    jsxImportSource: "jsx-view",
    // use in conjunction with providing your own `addJSXDev(fn)` handler
    // jsxDev: true,
  },
});

Contributing

Clone the repository with

git clone https://github.com/colelawrence/jsx-view.git

Open the repository in terminal, and install dependencies using pnpm.

cd jsx-view
pnpm install

Now, you have this locally, you may try things out by opening the dev server with

pnpm playground

About

Minimal reactive JSX UI builder designed to be highly ergonomic with MVVM

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages