Skip to content

arthurfranca/f

Repository files navigation

F

F is a javascript front-end framework just like React with same hooks. But by using HTML custom elements (web components) instead of JSX it doesn't need a build step and is future proof.

There are also signals hooks that don't exist on the React world. I recommend using them but pick the style you prefer.

Motivation

I didn't want to learn new javascript front-end frameworks.

Alternatives

There are alternatives that use custom elements but code is so big! F implementation is short and easy to understand. Well, it was short until signals support was added.

Installation

npm i thenameisf

Claude Code skill

This package ships a Claude Code skill at skills/thenameisf/ that teaches the agent how to write idiomatic thenameisf components. To make it available in a project that depends on thenameisf, symlink it into that project's .claude/skills/:

mkdir -p .claude/skills
ln -s ../../node_modules/thenameisf/skills/thenameisf .claude/skills/thenameisf

Commit the symlink and the whole team picks it up. Upgrading thenameisf upgrades the skill automatically.

Usage

There are two ways, functional and class-based.

Let's consider this html for both examples:

<!doctype html>
<html>
  <head>
    <script defer src="my-app.js"></script>
  </head>
  <body>
    <my-app></my-app>
  <body>
</html>

Functional

import { f, useState, useEffect } from 'thenameisf'

// this automatically defines "my-app" custom element (used on above HTML)
f(function myApp ({ html }) {
  const [test, setTest] = useState(1)
  useEffect(() => {
    const i = setInterval(() => {
      setTest(t => ++t)
    }, 2000)

    return () => clearInterval(i)
  }, [])

  return html`<my-child props=${{ test }} />`
})

// this automatically defines "my-child" custom element
f(function myChild ({ html, h, props }) {
  // html, h, this.html and this.h are the same
  // there's also svg, s, this.svg and this.s
  return this.h`<div>This is a test: ${props.test}<div>`
})

Note that custom elements require atleast two words for the tag name, that is, "myApp" becomes "my-app", while "app" wouldn't work.

Class-based

import { F, useState, useCallback } from 'thenameisf'

customElements.define('my-app', class extends F {
  // you can use all custom element methods
  connectedCallback () {() {
    super.connectedCallback()
    console.log('connected')
  }

  component () {
    const [test, setTest] = useState(1)
    const onClick = useCallback(() => setTest(t => ++t), [setTest])

    return this.html`<ul onclick=${onClick}>
      ${[...Array(test).keys()].map(i => this.html({ key: i })`<li>${++i}</li>`)}
    </div>`
  }
})

Available React-Compatible Hooks

  • useCallback
  • useEffect
  • useInsertionEffect
  • useMemo
  • useRef
  • useState

Extra Features

useGlobalState for shared state

The useGlobalState hook is like useState but shares the state across all components if using same namespace.

The first component to call useGlobalState with a specific namespace sets the initial value.

// on my-component-a.js
const [test, setTest] = useGlobalState('a-namespace', 1)
setTest(20)

// on my-component-b.js
// test === 20
const [test, setTest] = useGlobalState('a-namespace', 1)

useClosestState hook instead of useContext

The React's useContext hook equivalent is the useClosestState one that is like useState but shares the state from the closest ancestor component that called useClosestState with same namespace and a initial value.

// on my-component-grand-grand-parent.js
const [test, setTest] = useClosestState('a-namespace', 5)
setTest(20)

// on my-component-grand-parent.js
const [test, setTest] = useClosestState('a-namespace', 1)

// on my-component.js
// test === 1
const [test, setTest] = useClosestState('a-namespace')

Simplified event callbacks

You can use the function from useState directly as an event callback without resorting to useCallback.

const [value, setValue] = useState('')
return html`<input value={value} oninput={setValue} />`

No need to mutate array if its length changes

When adding/removing an item from a useState/useGlobalState's array state, there is no need to recreate the array to detect a change. Great for infinite loading.

const [array, setArray] = useState([0])
const onClick = useCallback(() => setArray(a => {
  // instead of return [...a, a.length]
  a.push(a.length); return a
}), [setArray])
return html`<div onclick=${onClick}>Array length: ${array.length}</div>`

Updating an existing array item would need a new array, though. Unless you add the eqKey symbol as key. F will check if its value has changed to determine if the array has changed (it can also be used for other object types).

// or import { eqKey } from 'thenameisf'
const onClick = useCallback(e => {
  setArray((a, eqKey) => {
    a[e.target.dataset.index] = 'new value'
    a[eqKey] = Math.random()
    return a // instead of returning [...a]
  })
}, [setArray])

Signals

Signals are an alternative reactive programming paradigm to React's useState.

It is more performant because when a signal value changes, just components that use the value will re-render, not the component that instantiated the signal. With useState, all the tree of components starting from the useState caller would re-render.

useSignal

useSignal returns an object with .get() and .set() methods similar to useState returned array's items. It also replaces useRef. If you call .get(false), it won't cause re-renders.

We adopted a $ suffix as a convention for signal variable names so that you know that the variable has a getter and a setter.

In the example below, clicks on the parent component's button won't re-render the parent, just the child.

f(function componentA ({ html }) {
  const count$ = useSignal(0) // or useSignal(() => 0) for lazy initial value
  return html`<div>
    <button onclick=${() => count$.set(v => ++v)}
    <component-b props=${{ count$ }} />
  </div>`
})

f(function componentB ({ html, props }) {
  return html`<span>${props.count$.get()}</span>`
})

Tip

You can use example$() and example$(newValue) call style instead of the more verbose example$.get() and example$.set(newValue).

If you just want to read the current value without subscribing to updates, use example$.peek(). It is an alias for example$.get(false).

It is useful inside contexts that subscribe by default, such as useComputed, useAsyncComputed, the callback passed to useTask's track, or directly inside a component's render body.

f(function counter ({ html }) {
  const count$ = useSignal(0)
  const delta$ = useSignal(1)

  // re-runs only when count$ changes, not when delta$ changes,
  // because delta$ is read with .peek()
  const sum$ = useComputed(() => count$.get() + delta$.peek())

  return html`<div>${sum$.get()}</div>`
})

Note that outside of subscribing contexts (e.g. in event handlers or directly in a useTask body but outside its track callback), reads don't subscribe anyway, so .peek() and .get() behave identically - .peek() is just a more explicit way to signal the intent.

It also works on computeds and on signals/computeds created through useStore.

The eqKey may be used here too if you don't want to return a new object on signal update. For example:

// or import { eqKey } from 'thenameisf'
signal$.set((prevValue, eqKey) => {
  prevValue.newItem = 3
  prevValue[eqKey] = Math.random()
  return prevValue
  // instead of return { ...prevValue, newItem: 3 }
})

useComputed

Drop-in replacement to useMemo. Difference is it doesn't need to list dependencies. It isn't compatible with useState values (can't track their updates).

It is used like a regular signal but it doesn't have a .set() setter. Nevertheless, we adopted the same $ suffix because the difference isn't big enough to justify using another one.

When a signal (or other computation) inside it changes, it schedules a refresh of its cached computed value for when its read somewhere with .get().

// the function argument can't be async
const doubleCount$ = useComputed(() => count$.get() * 2)
console.log(doubleCount$.get())

useStore

An easy way to group the creation of signals and computations. It looks at the suffixes of the object argument keys to know what to create.

const childProps = useStore({
  // signal, because it ends with $
  count$: 0
  // computed, because it ends with $ and is initialized with a function
  doubleCount$ () { return this.count$.get() * 2 },
  notReactive: 'Things won\'t re-render if I change',
  deep: {
    // signal
    example$: 3
  },
  aSignal$: { anotherSignal$: 'signal\'s signal' }
})
Lazy Initial Values

Pass a function to useStore to run just once any heavy computations required to initialize its values.

const childProps = useStore(() => {
  const count$ = heavyComputation() // can't return a function
  const doubleCount$ = function () { return this.count$.get() * 2 }
  const notReactive = 'Things won\'t re-render if I change'
  const deep = { example$: 3 }
  return {
    count$,
    // can't initialize a signal to a function value
    // because functions on fields with keys ending with $
    // will be used to initialize computations
    signalFunction$: undefined
    doubleCount$, // computed
    notReactive,
    deep
  }
})
// a store's signal field may be set to a function after the store initialization
useTask(() => {
  signalFunction$.set(function example () { /* ... */ })
})

Alternatively, its possible to lazy init a store's signal by passing a function with a strategy="signal" property:

const store = useStore({
  lazySignalExample$: (() => {
    // "heavyComputation" call may even return a function
    const fn = () => heavyComputation()
    fn.strategy = 'signal'
    return fn
  })()
})

useGlobalStore

Same thing as useStore but the first argument is a string that you can reference on other components. It's like useGlobalState but for stores.

// on my-component-a.js
const store = useGlobalStore('example123', { /* ... */ })

// on my-component-b.js
const sameStore = useGlobalStore('example123')

useGlobalSignal

Same thing as useGlobalStore but for storing a single signal.

const example$ = useGlobalSignal('<namespace>', <initial value>)

useGlobalComputed

Same thing as useGlobalStore but for storing a single computation.

const example$ = useGlobalComputed('<namespace>', <function>)

useClosestStore

This is closer to React's useContext behavior than useGlobalStore.

Gets the store from the closest ancestor that initialized a store with a specific namespace using useClosestStore with a second argument.

// on grandparent component
const store = useClosestStore('example123', { a$: 1 })
// on parent component
const store2 = useClosestStore('example123', { b$: 2 })

// on descendant component
const { b$ /* there's no a$ */ } = useClosestStore('example123')

useClosestSignal

Same thing as useClosestStore but for storing a single signal.

// on ancestor component
const example$ = useClosestSignal('<namespace>', <initial value>)
// on descendant component
const example2$ = useClosestSignal('<namespace>')

useClosestComputed

Same thing as useClosestStore but for storing a single computation.

// on ancestor component
const example$ = useClosestComputed('<namespace>', <function>)
// on descendant component
const example2$ = useClosestComputed('<namespace>')

useTask

Drop-in replacement to useEffect and useInsertionEffect.

Use the track function instead of the dependencies array. It can't track values from useState.

Use the cleanup function instead of returning a function.

Signature: useTask(fn, config)

Config argument (listed values are the default ones):

{
  // or 'visible' which would run just when component is visible
  when: 'init',
  // these are used for "when=visible" and work the same as the
  // options for the IntersectionObserver api
  root: null, rootMargin: '50%', threshold: 0
  // or 'rendering' which would run after the component html's first render
  // and it's good for waiting for refs set on components like <div ref=${signal$}>
  // to be available
  after: 'insertion',
  // or 'serial' which queues tasks sequentially
  execution: 'concurrent',
  // used for serial execution
  queueSize: 1
}

Example:

useTask(({ track, cleanup }) => {
  const value = track(() => aSignal$.get())

  const i = setInterval(() => {
    console.log(value)
  }, 2000)

  cleanup(() => clearInterval(i))
})

useAsyncComputed

Like useComputed, it has only a getter. The arguments and usage are identical to useTask, except that it expects you to return a value.

const computed$ = useAsyncComputed(async ({ track, cleanup }) => {
  track(() => aSignal$.get())

  const c = new AbortController()
  cleanup(() => c.abort())
  const person = await fetchPerson(c.signal)
  return person.name
})

console.log(computed$.get()) // starts undefined

You may initialize it with a value, or lazily with a function, before running the function body:

const computed$ = useAsyncComputed('Unknown Person', async () => { /* ... */ return 'Alice' })

The returned computation variable has a .promise$ signal property that is set to an object as follows:

{
  status: 'resolved' // pending / rejected
  hasValue: true, // false
  isLoading: true, // false
  error: null // thrown error
}

It may be paired with the accompanying component `:

import { f } from 'thenameisf'
import 'f/components/f-async-computed.js'

f(function myComponent ({ html }) {
  const asyncComputedExample$ = useAsyncComputed(async () => {
    const user = await fetchUser()
    return user
  })

  return html`
    <f-async-computed
      props=${{
        asyncComputed$: asyncComputedExample,
        onPending: () => this.h`Loading...`,
        onRejected: error => this.h`Error: ${error.message}`,
        onResolved: result => this.h`Success: ${result}`
      }}
    >
    <div>Can use it directly too: ${asyncComputedExample$.get() ?? 'not loaded yet'}</div>
  `
})

useUntrackedCallback

You probably won't need this because React's useCallback can already be used with signals/computeds.

const onClick = useCallback(() => signalB$.set(signalA$.get() + 1))

It works the same except that all calls to signals/computeds' .get()s are treated as if they were called as .get(false).

It could be useful if you don't control the function argument.

// same as const thisDoesntCauseRerenders = useCallback(() => console.log(signalA$.get(false)))
const thisDoesntCauseRerenders = useUntrackedCallback(() => console.log(signalA$.get()))
thisDoesntCauseRerenders()

useStateSignal

This is just if your fellow dev is using React paradigm while you are using signals. You use it to turn a useState or useMemo (or non-signal prop) into a signal.

const [state, setState] = useState(0)
const signal$ = useStateSignal(state, setState)

const memo = useMemo(() => state * 2, [state])
const computed$ = useStateSignal(memo)

const computed2$ = useStateSignal(props.example)

Component

Instead of "useStateSignal", you may use the "" component to inline-convert some props, like when you are mapping an array to components. This way you can easily pass all props you want as signals to be consistent.

import { f } from 'thenameisf'
import 'f/components/f-to-signals.js'

f(function myComponent ({ h }) {
  const users$ = useSignal(['Alice', 'Bob'])
  const test = 'Hi!'

  return h`${users$.get().map(user => h({ key: user })`
    <f-to-signals props=${{
      from: ['user'], // the user field will be turned into a user$ signal prop
      user,
      test,
      render: ({ html, props }) => { return html`<div>Hello ${props.user$()} - ${props.test}</div>` }
    }} />`
  `)}`
})

useSignalState

The inverse, to turn a signal into a state or a computed into a memo.

const signal$ = useSignal(3)
const [state /* 3 */, setState] = useSignalState(signal$)

const computed$ = useComputed(() => signal$.get() * 2)
const memo = useSignalState(computed$) // 6

Slots in light-DOM components

Shadow-DOM components (useShadowDOM: true) use the native <slot> element. Light-DOM components (the default) use <f-slot>, an f-provided custom element that reads slot content from a special children prop on its host.

Import once, anywhere it's needed:

import 'thenameisf/components/f-slot.js'

Default slot

The parent passes content via props.children. The child uses a bare <f-slot></f-slot> placeholder.

f(function myApp ({ html }) {
  return html`
    <my-card props=${{ children: html`<p>Hello world</p>` }} />
  `
})

f(function myCard () {
  return this.h`
    <article>
      <f-slot></f-slot>
    </article>
  `
})

Named slots

For multiple slots, pass a children object keyed by slot name. The bare <f-slot></f-slot> reads children.default. A bare html`...` value (without the object wrapper) still works for the default-only case - see above.

The slot name can be set via the HTML name attribute or via name/name$ props (signal wins over plain prop, which wins over attribute):

f(function myApp ({ html }) {
  return html`
    <my-layout props=${{
      children: {
        header:  html`<h1>Title</h1>`,
        default: html`<p>Body copy</p>`,
        footer:  html`<small>© 2026</small>`
      }
    }} />
  `
})

f(function myLayout () {
  return this.h`
    <header><f-slot name="header"></f-slot></header>
    <main><f-slot></f-slot></main>
    <footer><f-slot name="footer"></f-slot></footer>
  `
})

Using a signal for the name lets you swap which slot an <f-slot> reads at runtime:

const which$ = useSignal('header')
this.h`<f-slot props=${{ name$: which$ }}></f-slot>`

Fallback content

Static - whatever you put inside <f-slot> in the child's template is captured once on first mount and rendered when the slot is missing or nullish. No ${...} interpolation.

this.h`<f-slot name="header">Untitled</f-slot>`

Reactive - for dynamic fallback, pass a fallback (or fallback$ for a signal) prop on the <f-slot> element itself:

f(function myCard () {
  const count$ = useSignal(0)
  return this.h`
    <f-slot props=${{ fallback: this.h`<p>Nothing slotted yet</p>` }}></f-slot>
    <f-slot name="counter" props=${{ fallback$: useComputed(() => this.h`<span>Count: ${count$.get()}</span>`) }}></f-slot>
  `
})

The prop-based fallback is checked before the static one, so both can coexist

  • the prop wins when present.

Signal-driven slots

<f-slot> is itself an f component, so anything it reads with .get() auto-subscribes its own observer. That means slot content can be a signal at any level - and only the affected slot re-renders when the signal fires; the host doesn't.

Top-level signal: children$
f(function myApp () {
  const children$ = useSignal(this.h`<p>v1</p>`)
  useEffect(() => { setTimeout(() => children$.set(this.h`<p>v2</p>`), 1000) }, [])
  return this.h`<my-card props=${{ children$ }} />`
})

<f-slot> inside myCard reads props.children$ (note the $), .get()s it, and re-renders when the signal fires. myCard itself does not re-render.

Per-slot signals (mixed)

Use a $ suffix on individual keys. <f-slot name="header"> resolves children.header first, then children.header$. Plain wins so you can override a signal slot without touching the parent.

f(function myApp () {
  const header$ = useComputed(() => `Hello, ${user$.get()}`)
  const [defaultBodyExtra, setDefaultBodyExtra] = useState('Extra')
  return this.h`
    <my-layout props=${{
      children: {
        header$,                                            // signal
        default: this.h`<p>Body - ${defaultBodyExtra}</p>`, // React-like
        footer:  this.h`<small>© 2026</small>`              // static
      }
    }} />
  `
})
Combined: children$ yielding an object that itself has signal keys

This is supported. <f-slot> first .get()s the outer signal (subscribing to it), then reads the resolved key - calling .get() again if that key is itself a signal (subscribing to that one too). When the outer signal fires with a new object whose header$ is a different signal instance, f-slot re-runs, drops its subscription to the old header$, and subscribes to the new one. The old signal can fire at most once more before its observer set is emptied - a wasted render, never an incorrect one.

const children$ = useSignal({
  header$: useSignal('Hi'),
  default: this.h`<p>Body</p>`
})

Why not native <my-card><p>Hi</p></my-card> syntax?

F uses uhtml as its underlying HTML rendering engine - that's what powers the html`...` tag you see in every example. When a parent template includes <my-card><p>Hi</p></my-card>, uhtml inserts <p>Hi</p> as real DOM children of <my-card> before <my-card> gets a chance to render its own template; the child's render would then clobber them. The explicit children prop sidesteps that race and integrates with the existing prop-diffing path (components/component.js). For shadow-DOM components, keep using native <slot> - set useShadowDOM: true.

Routing

Client-side routing combines the useLocation hook with the <f-route> component. Both work on top of any url-router-compatible router instance, so install that alongside f:

npm i url-router

Setting up the router

Create a router with path-to-handler mapping, then call useLocation(router) inside the component that owns the route. Each handler provides a tag (the custom element name to render) and a loadModule function used for code splitting.

import Router from 'url-router'
import { f, useLocation } from 'thenameisf'
import 'thenameisf/components/f-route.js'

const router = new Router({
  '/': { path: '/', tag: 'my-home', loadModule: () => import('./views/home.js') },
  '/about': { path: '/about', tag: 'my-about', loadModule: () => import('./views/about.js') },
  // params become available via loc.route$().params.npub
  '/:npub(npub1.*)': { path: '/:npub(npub1.*)', tag: 'my-profile', loadModule: () => import('./views/profile.js') },
  '/(.*)': { path: '/(.*)', tag: 'my-not-found', loadModule: () => import('./views/not-found.js') }
})

f(function myApp ({ html }) {
  useLocation(router)

  return html`
    <f-route props=${{ path: '/' }} />
    <f-route props=${{ path: '/about' }} />
    <f-route props=${{ path: '/:npub(npub1.*)' }} />
    <f-route props=${{ path: '/(.*)', shouldPreload: true }} />
  `
})

The path prop on <f-route> must match a key from the router definition. Use paths (array) when a single component should mount for multiple paths — the router entries on those paths are expected to share the same tag and loadModule:

// in the router, both '/help' and '/faq' point to the same handler
<f-route props=${{ paths: ['/help', '/faq'] }} />

Reading the current location

Any descendant component may call useLocation() (with no argument) to get the closest location store:

f(function myPage ({ html }) {
  const loc = useLocation()
  const route = loc.route$()
  // route.url      — URL object with pathname, searchParams, etc.
  // route.params   — matched route params (e.g. { npub: 'npub1abc...' })
  // route.state    — history.state contents
  // route.handler  — matched router entry ({ path, tag, loadModule })
  // route.uid      — monotonic id used by <f-route> for stacking/distance
  return html`<h1>Pathname: ${route.url.pathname}</h1>`
})

Navigation helpers on loc:

loc.pushState(state, '', '/next')    // new history entry
loc.replaceState(state, '', '/same') // in-place update
loc.back()
loc.forward()
loc.go(-2)
loc.getRouterMatch('/some/path')     // => { handler, params } | null

Nested routers

A component loaded by a parent <f-route> may itself call useLocation(subRouter) to scope routing to its sub-tree. Descendants of that component see the nested router via useLocation(); descendants of the outer router (outside the sub-tree) still see the outer one.

// loaded for '/(.*)' by the outer router
import Router from 'url-router'
import { f, useLocation } from 'thenameisf'
import 'thenameisf/components/f-route.js'

const router = new Router({
  '/upload': { path: '/upload', tag: 'home-upload', loadModule: () => import('./upload.js') },
  '/(.*)':   { path: '/(.*)',   tag: 'home-index',  loadModule: () => import('./index.js') }
})

f(function homeRouter () {
  useLocation(router)
  return this.h`
    <f-route props=${{ path: '/(.*)' }} />
    <f-route props=${{ path: '/upload' }} />
  `
})

<f-route> props

  • path / path$ — path key from the router to match.
  • paths / paths$ — array form, when one component should mount for multiple paths.
  • shouldPreload / shouldPreload$ — default false. When true, the module is imported and rendered as soon as the app starts, regardless of the current path. Useful for the fallback /(.*) route so it's ready instantly.
  • maxVisibleDistance / maxVisibleDistance$ — default 0. Keeps the previous route visible while the user navigates through nearby history entries. Useful for overlay patterns where a previous view shouldn't unmount immediately.
  • shouldUpdateUidWhenPathMatches / shouldUpdateUidWhenPathMatches$ — default true. Controls whether repeated matches re-bump the stored uid used by maxVisibleDistance.

Signal variants ($-suffixed) let the value change over time without re-rendering the host component.

Route data inside the loaded component

When <f-route> renders its matched component, it passes a route$ prop — a signal holding { uid, url, state, params } (no handler, to avoid leaking the router definition):

f(function myProfile ({ html, props }) {
  const { npub } = props.route$().params
  return html`<div>Profile: ${npub}</div>`
})

It's also exposed as a closest store, so deeper descendants can read the same signal via useClosestStore('<f-route>').

Hot Reloading

There is no hot reloading support but you may use server-sent events and a file watcher to reload the web app on development on file changes.

You should set the flag globalThis._F_SHOULD_RESTORE_STATE_ON_TAB_RELOAD to true to remember hooks' state between tab reloads. It temporarily stores the state on window.sessionStorage.

Depending on the way you manage your app routes, after clicking some links different components may render on tab reload. To keep correct hook state, add a fixed id to your components like <a-component id='f7dcce56a4bcf' /><b-component id='62e7ff6526ba' />

See /example/server.js or read esbuild live reload docs if you need a build step. Also see how we set that global flag to true and listen to server-sent events at /example/index.html.

On reloads, useTask and useAsyncComputed have a isHotStart argument for the function they use. You can do if (isHotStart) return to avoid re-running these hooks.

Other hooks have an additional shouldCache config you may set to false to skip restore. For example: useSignal('test', { shouldCache: false })

Ad-hoc Usage

There are exports available for toSignal, toComputed and toStore for manual usage. They don't have the same options as their hook versions such as shouldCache.

Hooks are easier to use because they are automatically discarded when the component unmounts.

toTask and toAsyncComputed are missing for now.

About

F is a framework like React but uses HTML custom elements instead of JSX

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors