Build interfaces with mini-react

A hands-on tutorial. You'll go from your first element to a complete small app, one idea at a time. If you know React, most of this will feel familiar.

Every example on this page is live. Change the code and press Run (or Ctrl+Enter). Reset brings back the original. Anything you pass to log() appears under the result.
Lesson 1

Setup and your first render

mini-react is a single file. Copy mini-react.global.js from the dist folder next to your HTML page and load it with a normal script tag. It creates one global object, MiniReact, with everything in it.

index.html<div id="app"></div>

<script src="mini-react.global.js"></script>
<script>
  const { h, createRoot } = MiniReact;

  const app = h('h1', null, 'Hello, world!');
  createRoot(document.getElementById('app')).render(app);
</script>

That page works when you double-click it, with no server and no build step. Two functions do all the work:

  • h(type, props, ...children) describes a piece of UI. It returns a plain object. Nothing appears on the page yet.
  • createRoot(element).render(ui) takes that description and builds the real page inside element.

Try it below. In the examples, container is the result area, and all the functions from MiniReact are already available.

Try this: change the text, or change 'h1' to 'h3', and press Run.

Lesson 2

Elements, props and children

The first argument of h() is a tag name. The second is an object of props (attributes, styles, events), or null. Everything after that is children: text, numbers, other elements or arrays of them.

  • Use className for CSS classes and htmlFor on labels, like in React.
  • style takes an object. Numbers get px added, except for things like opacity and zIndex.
  • null, false and true render nothing. That makes condition && h(...) a handy way to show something only sometimes.
  • An array of elements renders each item. Give each one a key (more in Lesson 6).

Try this: set online to false, and add a fourth task to the list.

Lesson 3

Components

A component is a function that takes props and returns elements. Use it by passing the function itself to h() instead of a tag name: h(Avatar, {'{'} name: 'Ada' {'}'}). Component names start with a capital letter by convention.

Whatever you put between the tags arrives as props.children. That lets you build wrappers like cards, dialogs and layouts.

Try this: give one Avatar a size of 64, and add a third UserCard.

A component can return an element, a string, an array, or null to render nothing. To return several elements without a wrapper div, use a Fragment: h(Fragment, null, a, b, c).

Lesson 4

State with useState

Components remember values with state. useState(initial) returns the current value and a function to change it. When you call that function, mini-react runs your component again and updates the page where something changed.

Watch the log as you click. The "+2" button calls setCount twice, but the component renders only once. Updates made at the same time are batched into one render.

Use the function form setCount(c => c + 1) when the new value depends on the old one. With setCount(count + 1) twice, both calls would see the same old count, and you'd only get +1.

Objects and arrays: make a new copy

mini-react compares the old and new state with Object.is. If you change an object in place and pass the same object back, nothing looks different, so nothing re-renders. Create a new object or array instead, with spread (...), map or filter.

Try this: uncomment the "wrong" button and click it. The list doesn't update until something else causes a render.

Lesson 5

Events and forms

Props that start with on are event handlers: onClick, onInput, onKeyDown, onSubmit, onMouseEnter and so on. The handler gets the browser's normal event object.

For form fields, pass the value from state and update the state in onChange. This is called a controlled input: state is the single source of truth, so you can validate, reset or transform the value at any time. Like in React, onChange on a text field fires on every keystroke.

  • Text fields and selects use value. Checkboxes and radio buttons use checked.
  • One update function handles every field, by reading the field's name.
  • Call e.preventDefault() in onSubmit, or the browser reloads the page.
Lesson 6

Lists and keys

To render a list, map your data to elements. Give each element a key that is unique among its siblings and stays the same for the same item, such as an id from your data.

Keys tell mini-react which item is which between renders. When the list is reordered or an item is removed, each existing item keeps its page element and its state, and only the moved items are touched.

Avoid using the array index as the key for lists that can be reordered or filtered. When an item is removed, every item after it gets a new index, and state can end up attached to the wrong item. The "Why keys matter" panel in the demos shows this side by side.

Lesson 7

Effects with useEffect

Rendering should only calculate what to show. Everything else is a side effect: timers, requests, subscriptions, or working with the page directly. Put those in useEffect(fn, deps). It runs after the page is updated.

  • deps is a list of values. The effect runs again only when one of them changes.
  • [] means "only after the first render".
  • Leave out deps to run after every render.
  • Return a cleanup function to undo the effect. It runs before the effect runs again, and when the component is removed.

Click "Hide clock" and watch the log. Without the cleanup, the timer would keep running after the clock is gone.

Loading data

Effects are also where you load data. Put everything the request depends on in deps. Use a flag in the cleanup to ignore answers that arrive after a newer request started, or a slow old answer could replace a newer one.

Try this: type fast. The log shows which answers were ignored because a newer search had already started.

Lesson 8

Refs with useRef

useRef(initial) gives you a box, {'{'} current: initial {'}'}, that keeps its content between renders. It has two uses:

  • Reaching page elements. Pass the ref as the ref prop. After rendering, ref.current is the real element, so you can call focus(), measure it, or draw on a canvas.
  • Remembering values without re-rendering. Changing ref.current does not cause a render. Use it for timer ids, previous values or counters you don't display.

Only read refs to elements in effects or event handlers. During the first render the element doesn't exist yet, so ref.current is still null.

Lesson 9

Complex state with useReducer

When state has several related parts and many ways to change, keep all the logic in one function, a reducer. It receives the current state and an action that describes what happened, and returns the new state. Components only dispatch actions.

The reducer is a plain function with no mini-react in it, so it's easy to read and to test on its own. The same rule as useState applies: always return a new object when something changes.

Lesson 10

Sharing data with context

Passing the same prop through many layers gets tiring. Context lets a component far down the tree read a value directly.

  1. Create it: const ThemeContext = createContext('light'). The argument is the default value.
  2. Provide a value: h(ThemeContext.Provider, {'{'} value: theme {'}'}, ...children).
  3. Read it anywhere below: const theme = useContext(ThemeContext).

When the provided value changes, every component that reads it re-renders, even if a component in between uses memo (Lesson 12).

Lesson 11

Your own hooks

A custom hook is a function whose name starts with use and that calls other hooks. It lets you reuse stateful logic between components. Each component that calls it gets its own separate state.

useInterval keeps the latest callback in a ref. The interval itself only restarts when the delay changes, but it always calls the newest version of your function, with the newest state.

The rules of hooks. Call hooks at the top level of a component or a custom hook, in the same order on every render. Don't call them inside if, loops or event handlers. mini-react finds each hook's data by its position in the call order, like React does.

Lesson 12

Performance: memo, useMemo, useCallback

When a component renders, its children render too. That's usually fast enough, because mini-react only changes the parts of the page that actually differ. For big lists or slow components, three tools help:

ToolWhat it doesUse it for
memo(Component)Skips rendering a component when its props are the same as last time (a shallow comparison).List rows and other components that render often with the same props.
useMemo(fn, deps)Remembers the result of fn until a dependency changes.Slow calculations like filtering or sorting a big list.
useCallback(fn, deps)Keeps the same function between renders.Handlers you pass to memo components. A new function on every render would count as a changed prop.

Try this: type in the note field, and the log stays quiet because the rows are skipped. Then remove useCallback (keep the arrow function) and type again. Every row renders on each keystroke, because onToggle is a new function every time.

memo takes an optional second argument, areEqual(prevProps, nextProps). Return true to skip the render.

Lesson 13

Putting it together: a reading list

This app uses almost everything from the tutorial:

  • A reducer for the books.
  • Context to hand dispatch to any component.
  • A controlled form, with a ref that puts focus back into the title field after you add a book.
  • memo rows with keys.
  • useMemo for the filtered list and the stats.

Read it from the bottom up: App first, then the pieces it uses.

Ideas to extend it: sort by rating, add a "delete" button, or show the average rating of the books you've read.

Lesson 14

JSX and ES modules

Everything so far used h() directly, so it runs without any tools. For bigger projects you may prefer JSX, the HTML-like syntax React uses. JSX is turned into h() calls by a build tool. These two are the same:

Without JSXh('button', { className: 'primary', onClick: save }, 'Save ', count)
With JSX<button className="primary" onClick={save}>Save {count}</button>

To use JSX, import from the module version, mini-react.js, and tell your build tool to use h and Fragment. With esbuild:

app.jsximport { h, Fragment, createRoot, useState } from './mini-react.js';

function App() {
  const [name, setName] = useState('Vlad');
  return (
    <>
      <input value={name} onChange={(e) => setName(e.target.value)} />
      <p>Hello, {name}!</p>
    </>
  );
}

createRoot(document.getElementById('app')).render(<App />);
Terminalnpx esbuild app.jsx --bundle --jsx-factory=h --jsx-fragment=Fragment --outfile=app.js

Then load app.js with a normal script tag. In Vite, set esbuild: {'{'} jsxFactory: 'h', jsxFragment: 'Fragment' {'}'} in vite.config.js.

Modules without a build step: <script type="module"> with import {'{'} h {'}'} from './mini-react.js' also works, but browsers only allow modules from a web server, not from a file you double-click. Run npx serve . in the folder first.

Lesson 15

Common mistakes

ProblemWhy it happensFix
The page doesn't update after I change state.You changed an object or array in place, so the new state is the same object.Create a new one: [...list, item], {'{'} ...obj, x: 1 {'}'}.
The DOM still shows the old value right after setState.Updates are batched and applied a moment later, together.Read the new value in the next render or in useEffect. In tests, wrap the update in flushSync(() => ...).
A counter adds 1 instead of 2.Both calls used the same old value.setCount(c => c + 1).
"Too many re-renders".setState is called during rendering, or in an effect that runs after every render.Move it into an event handler, or give the effect a deps list.
A timer or listener keeps running after the component is gone.The effect has no cleanup.Return a function that clears it.
An interval always sees the first value of state.The callback was created in the first render and never updated (a "stale closure").Use the function form of the setter, or the useInterval hook from Lesson 11.
Hook data gets mixed up.A hook was called inside an if or a loop, so the order changed.Call hooks unconditionally at the top of the component.
List items lose their state or show the wrong data.Missing keys, or the index is used as the key.Use a stable id: key: item.id.
The form reloads the page.The browser submitted the form.e.preventDefault() in onSubmit.

Differences from React

  • No class components, portals, Suspense, error boundaries or server rendering.
  • defaultValue, defaultChecked and dangerouslySetInnerHTML aren't supported. Use controlled inputs, or set things through a ref.
  • Handlers receive the browser's own event, not a React "synthetic" event. Listeners are attached to each element.
  • useLayoutEffect behaves the same as useEffect. Both run right after the page is updated, before the browser paints.
  • Rendering can't be split into smaller pieces. There is no startTransition, so a very large update renders in one go.
Reference

API cheat sheet

FunctionWhat it does
h(type, props, ...children)Describe an element. type is a tag name, a component or Fragment. Also exported as createElement.
FragmentGroup children without a wrapper element.
createRoot(el).render(ui)Render into a page element. Call render again to update, unmount() to remove.
render(ui, el)Short form of the line above.
useState(initial)[value, setValue]. initial can be a function, called only once.
useReducer(reducer, initialArg, init?)[state, dispatch].
useEffect(fn, deps?)Run fn after rendering when deps change. fn may return a cleanup.
useLayoutEffect(fn, deps?)Same as useEffect in mini-react.
useRef(initial)A {'{'} current {'}'} box that survives renders. Pass as ref to get an element.
useMemo(fn, deps)Cache a calculated value.
useCallback(fn, deps)Cache a function.
createContext(default)Make a context with a .Provider component.
useContext(ctx)Read the nearest provider's value.
memo(Component, areEqual?)Skip re-rendering when props are equal.
flushSync(fn?)Run fn and apply all pending updates immediately.