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.
log() appears under the result.
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 insideelement.
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.
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
classNamefor CSS classes andhtmlForon labels, like in React. styletakes an object. Numbers getpxadded, except for things likeopacityandzIndex.null,falseandtruerender nothing. That makescondition && 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.
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).
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.
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 usechecked. - One
updatefunction handles every field, by reading the field'sname. - Call
e.preventDefault()inonSubmit, or the browser reloads the page.
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.
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.
depsis a list of values. The effect runs again only when one of them changes.[]means "only after the first render".- Leave out
depsto 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.
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
refprop. After rendering,ref.currentis the real element, so you can callfocus(), measure it, or draw on a canvas. - Remembering values without re-rendering. Changing
ref.currentdoes 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.
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.
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.
- Create it:
const ThemeContext = createContext('light'). The argument is the default value. - Provide a value:
h(ThemeContext.Provider, {'{'} value: theme {'}'}, ...children). - 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).
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.
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:
| Tool | What it does | Use 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.
Putting it together: a reading list
This app uses almost everything from the tutorial:
- A reducer for the books.
- Context to hand
dispatchto any component. - A controlled form, with a ref that puts focus back into the title field after you add a book.
memorows with keys.useMemofor 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.
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.
Common mistakes
| Problem | Why it happens | Fix |
|---|---|---|
| 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,defaultCheckedanddangerouslySetInnerHTMLaren'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.
useLayoutEffectbehaves the same asuseEffect. 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.
API cheat sheet
| Function | What it does |
|---|---|
h(type, props, ...children) | Describe an element. type is a tag name, a component or Fragment. Also exported as createElement. |
Fragment | Group 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. |
Where to go next
Browse the 13 live demos for more complete examples, including a drawing pad, a validated sign-up form and the Game of Life. Or read mini-react.js itself. It's about 700 lines, and every part is commented.