NOTE: Project is still a work-in-progress
Pepper is a JS view library with DOM rendering, components, pseudo-hydration, SSR, portals, and error boundaries.
Bundle size - 10 KB gzipped. Add +1 KB if you use pepper/store.
import {
state,
ref,
stableId, // like useId()
render,
hydrate,
type ComponentSetupApi,
} from '@pepper-js/pepper'
import { renderComponentToString } from '@pepper-js/pepper/ssr'
type TodoItem = {
id: number
label: string
}
type TodoRowProps = {
label: string
}
function TodoRow({ getProps }: ComponentSetupApi<TodoRowProps>) {
return html => html`<li>${getProps().label}</li>`
}
function TodoApp({ onMount, onProps }: ComponentSetupApi) {
const [getItems, setItems] = state<TodoItem[]>([
{ id: 1, label: 'Write docs' },
{ id: 2, label: 'Ship demo' },
])
const [getNextId, setNextId] = state(3)
const inputRef = ref<HTMLInputElement>()
function addItem() {
const label = inputRef.current?.value.trim()
if (!label) return
const nextId = getNextId()
setItems([...getItems(), { id: nextId, label }])
setNextId(nextId + 1)
inputRef.current.value = ''
inputRef.current.focus()
}
onProps((changedProps, oldProps) => {
changedProps
oldProps
})
onMount(() => {
console.log('mounted')
return () => console.log('unmounted')
})
return html => html`
<input ref=${inputRef} placeholder="New todo" />
<button @click=${addItem}>Add</button>
<ul>
${getItems().map(item => html`<${TodoRow} key=${item.id} label=${item.label} />`)}
</ul>
`
}
render(TodoApp, '#app')
hydrate(TodoApp, document.getElementById('app'))
renderComponentToString(TodoApp)npm run demo
Check examples/components.html shows nested components, keyed child lists, and spread props.
npm install @pepper-js/pepperInstall VSCode extension from tooling/pepper-vscode/pepper-vscode.vsix
Pepper's browser build is an ES module, so it can be imported directly from a CDN:
<script type="module">
import { html, render, state } from 'https://unpkg.com/@pepper-js/pepper/dist/index.js'
function Counter() {
const [getCount, setCount] = state(0)
return html => html`
<button @click=${() => setCount(getCount() + 1)}>
${getCount()}
</button>
`
}
render(Counter, '#app')
</script>Pepper v0.3 does not publish a global/IIFE build; use a module script or bundle the package.
Setup API:
getProps()getError()- returns the currently captured error for an error-boundary component, otherwisenullonProps(handler)- runs on later prop changes, not on initial mountonMount(handler)resetError()- clears the captured boundary error and retries renderingupdate(callback?)- callback is called after a re-render
Direct imports:
const [getState, setState] = state(initialValue, comparator?)setState(newState, falseOrCallback?)- Updates are batched into a JS microtask.
- If 2nd param is false, then no re-render is scheduled.
- If 2nd param is a function, it's called after re-render.
ref()stableId()portal(target, renderable)
Components may return:
- a render function
- an object with
render(html)
Use stableId() during component setup when related DOM attributes need the same deterministic id across rerenders, SSR, and hydration (it's similar to react useId()):
import { stableId } from '@pepper-js/pepper'
function Field() {
const id = stableId()
return html => html`
<label for=${id}>Name</label>
<input id=${id} />
`
}When a page has multiple SSR roots, pass the same identifierPrefix to server render and client hydrate for each root:
import { renderComponentToString } from '@pepper-js/pepper/ssr'
const html = renderComponentToString(Field, {}, { identifierPrefix: 'checkout-' })
hydrate(Field, '#checkout-field', {}, { identifierPrefix: 'checkout-' })Pass { debugKeys: true } to render() or hydrate() to stamp keyed child component root elements with x-key="...":
render(App, '#app', {}, { debugKeys: true })This is intended for inspecting keyed component identity in devtools. Do not rely on x-key in application code.
Do not reuse one html\...`` output in multiple holes:
function BadExample() {
return html => {
const icon = html`<span>!</span>`
return html`
<div>${icon}</div>
<div>${icon}</div>
`
}
}Instead, create a fresh html\...`` value per hole:
function GoodExample() {
return html => html`
<div>${html`<span>!</span>`}</div>
<div>${html`<span>!</span>`}</div>
`
}Pepper roots accept a 4th-param context object. This is the intended way to pass request-local stores through the tree for both SSR and hydration.
import { hydrate } from '@pepper-js/pepper'
import { renderComponentToString } from '@pepper-js/pepper/ssr'
import { Store } from '@pepper-js/pepper/store'
type CartItem = {
id: string
qty: number
}
type CartData = {
items: CartItem[]
}
type CartStore = Store & {
data: CartData
assign(partial: Partial<CartData>): void
}
type AppContext = {
cart: CartStore
}
function CartCount({ getContext, onMount, update }: import('@pepper-js/pepper').ComponentSetupApi<{}, AppContext>) {
const cart = getContext('cart')
const onCartChange = () => update()
onMount(() => {
cart.subscribe(['items'], onCartChange)
return () => cart.unsubscribe(onCartChange)
})
return html => html`${cart.data.items.length}`
}
function HeaderCart() {
return html => html`<span>Cart: <${CartCount} /></span>`
}
function SidebarCart() {
return html => html`<aside>Items: <${CartCount} /></aside>`
}
// client: two islands sharing the same Store instance
const cart = new Store(window.initialCart as CartData) as CartStore
hydrate(HeaderCart, '#header-cart', {}, { context: { cart } })
hydrate(SidebarCart, '#sidebar-cart', {}, { context: { cart } })
// server: same API, but per-request data
const ssrCart = new Store(cartData) as CartStore
const headerHtml = renderComponentToString(HeaderCart, {}, { context: { cart: ssrCart } })
const sidebarHtml = renderComponentToString(SidebarCart, {}, { context: { cart: ssrCart } })Context API available inside components:
getContext(key)setContext(key, value)hasContext(key)
Store is a small external state container intended to be passed through Pepper context.
import { Store } from '@pepper-js/pepper/store'
const cart = new Store({ items: [] })
cart.data
cart.data = { items: ['a'] }
cart.assign({ items: ['a', 'b'] })
const onCartChange = changedProps => {
console.log(changedProps)
}
cart.subscribe(['items'], onCartChange)
cart.unsubscribe(onCartChange)Notes:
- change detection is shallow, top-level only
store.data = nextDatareplaces the whole data objectstore.assign(partial)shallow-merges into the existing data object- subscribers are notified only for the top-level keys they subscribed to
Plain function components use Pepper’s default runtime behavior:
- props are deep-compared before parent-driven rerenders
- function props named
onX(onSave,onChange) are ignored in that comparison
Wrap a component with component(factory, options) to change this.
Supported options:
memodefaults totrue; props are deep-compared and equal props skip parent-driven rerenders. Setfalseto compare by reference withObject.isautoEffectEventdefaults totrue;onXfunction props are treated like effect events: their latest value is stored, but changing only them does not rerender the child¹. Setfalseto include them in memo checkspropsComparatordefaults tonull; when provided, returntrueto skip rerendererrorBoundarydefaults tofalse; settrueto catch descendant setup/render errors
¹ By the way, template event bindings already keeps one DOM listener and update its current callback when the template rerenders, so handler changes do not need remove/add listener churn. However autoEffectEvent is added optimization at the component boundary level.
Pepper supports paired component tags for layout-style composition:
function Layout({ getProps }) {
return html => html`
<section class="layout">
<header>${getProps().title}</header>
<main>${getProps().children?.()}</main>
</section>
`
}
function Screen() {
return html => html`
<${Layout} title=${'Settings'}>
<span>${'inside'}</span>
</${Layout}>
`
}Rules:
key=${...}is reserved for component identity in child-component lists...${spreadProps}works on child component tags- paired tags pass lazy
children()to the child component - named slots are not supported yet
Use portal(target, renderable) to render a subtree into another DOM container while keeping it owned by the current Pepper component.
import { portal } from '@pepper-js/pepper'
function Dialog({ getProps }) {
return html => html`
<section class="dialog">
${getProps().title}
</section>
`
}
function App() {
return html => html`
<div>${'page content'}</div>
${portal('#modal-root', html`<${Dialog} title=${'Settings'} />`)}
`
}Notes:
targetcan be a DOM element or selector- portal children still get normal Pepper lifecycle and context
- SSR omits portal output
Wrap a component with component(..., { errorBoundary: true }) to let it catch descendant render/setup errors and render fallback UI.
import { component } from '@pepper-js/pepper'
const ErrorBoundary = component(function ErrorBoundary({ getError, getProps, resetError }) {
return {
render(html) {
const error = getError()
if (error) {
return html`<button @click=${resetError}>Retry</button>`
}
return getProps().children?.()
},
}
}, {
errorBoundary: true,
})
function Buggy({ getProps }) {
return html => {
if (getProps().crash) throw new Error('boom')
return html`<span>${'ok'}</span>`
}
}
function App() {
return html => html`
<${ErrorBoundary}>
<${Buggy} crash=${true} />
</${ErrorBoundary}>
`
}Notes:
- boundaries catch descendant render/setup failures, not event-handler errors
getError()returnsnullwhen healthyresetError()clears the boundary state and retries rendering
The repo now includes in-repo tooling packages:
tooling/pepper-template-analyzertooling/pepper-typescript-plugintooling/pepper-linttooling/pepper-vscode
VS Code extension:
- rebuild:
npm run vscode:package - install locally:
npm run vscode:install - repo bundle path:
tooling/pepper-vscode/pepper-vscode.vsix
Pepper targets modern browsers:
- Safari 15.6+
- latest Chrome
- latest Edge
- latest Firefox
- latest Samsung Internet