kida

1.0.0-alpha.2 • Public • Published

Kida

ESM-only package NPM version Dependencies status Install size Build status Coverage status

A small state management library inspired by Nano Stores and based on Agera.

  • Small. Around 1.94 kB for basic methods (minified and brotlied). Zero dependencies.
  • ~20x faster than Nano Stores.
  • Designed for best Tree-Shaking: only the code you use is included in your bundle.
  • TypeScript-first.
// store/users.ts
import { signal, push } from 'kida'

export const $users = signal<User[]>([])

export function addUser(user: User) {
  push($users, user)
}
// store/admins.ts
import { computed } from 'kida'
import { $users } from './users.js'

export const $admins = computed(() => $users().filter(user => user.isAdmin))
// components/admins.ts
import { record } from 'kida'
import { $admins } from '../stores/admins.js'

export function Admins() {
  return ul()(
    for$($admins, user => user.id)(
      $admin => li()(record($admin).name)
    )
  )
}

Install   •   Basics   •   Complex data types   •   Extra signals   •   Tasks   •   SSR   •   Utils

Install

pnpm add -D kida
# or
npm i -D kida
# or
yarn add -D kida

Basics

Signal

Signal is a basic store type. It stores a single value.

import { signal, update } from 'kida'

const $count = signal(0)

$count($count() + 1)
// or
update($count, count => count + 1)

To watch signal changes, use the effect function. Effect will be called immediately and every time the signal changes.

import { signal, effect } from 'kida'

const $count = signal(0)

const stop = effect(() => {
  console.log('Count:', $count())

  return () => {
    // Cleanup function. Will be called before effect update and before effect stop.
  }
})
// later you can stop effect
stop()

Computed

Computed is a signal that computes its value based on other signals.

import { computed } from 'kida'

const $firstName = signal('John')
const $lastName = signal('Doe')
const $fullName = computed(() => `${$firstName()} ${$lastName()}`)

console.log($fullName()) // John Doe

effectScope

effectScope creates a scope for effects. It allows to stop all effects in the scope at once.

import { signal, effectScope, effect } from 'kida'

const $a = signal(0)
const $b = signal(0)
const stop = effectScope(() => {
  effect(() => {
    console.log('A:', $a())
  })

  effectScope(() => {
    effect(() => {
      console.log('B:', $b())
    })
  })
})

stop() // stop all effects

Also there is a possibility to create a lazy scope.

import { signal, effectScope, effect } from 'kida'

const $a = signal(0)
const $b = signal(0)
// All scopes will run immediately, but effects run is delayed
const start = effectScope(() => {
  effect(() => {
    console.log('A:', $a())
  })

  effectScope(() => {
    effect(() => {
      console.log('B:', $b())
    })
  })
}, true) // marks scope as lazy
// start all effects
const stop = start()

stop() // stop all effects

onMountEffect

onMountEffect accepts a signal as a first argument to start effect on this signal mount.

import { signal, onMountEffect } from 'kida'

const $weather = signal('sunny')
const $city = signal('Batumi')

onMountEffect($weather, () => {
  $weather(getWeather($city()))
})

onMountEffectScope

onMountEffectScope accepts a signal as a first argument to run effect scope on this signal mount.

import { signal, onMountEffectScope, effect } from 'kida'

const $weather = signal('sunny')
const $city = signal('Batumi')

onMountEffectScope($weather, () => {
  effect(() => {
    console.log('Weather:', $weather())
  })

  effect(() => {
    console.log('City:', $city())
  })
})

Lifecycles

One of main feature of Kida is that every signal can be mounted (active) or unmounted (inactive). It allows to create lazy signals, which will use resources only if signal is really used in the UI.

  • Signal is mounted when one or more effects is attached to it.
  • Signal is unmounted when signal has no effects.

onMount lifecycle method adds callback for mount and unmount events.

import { signal, onMount, effect } from 'kida'

const $count = signal(0)

onMount($count, () => {
  // Signal is now active
  return () => {
    // Signal is going to be inactive
  }
})

// will trigger mount event
const stop = effect(() => {
  console.log('Count:', $count())
})
// will trigger unmount event
stop()

For performance reasons, signal will move to disabled mode with 1 second delay after last effect unsubscribing. It allows to avoid unnecessary signal updates in case of fast mount/unmount events.

There are other lifecycle methods:

  • onStart($signal, () => void): first effect was attached. Low-level method. It is better to use onMount for simple lazy signals.
  • onStop($signal, () => void): last effect was detached. Low-level method. It is better to use onMount for simple lazy signals.

start

start method starts signal and returns function to stop it. It can be useful to write tests for signals.

import { signal, onMount, start } from 'kida'

const $count = signal(0)

onMount($count, () => {
  console.log('Signal started')
})

const stop = start($count) // Signal started

stop()

exec

exec method starts and immediately stops signal. It can be used to trigger onMount events.

import { signal, onMount, exec } from 'kida'

const $count = signal(0)

onMount($count, () => {
  console.log('Signal started')
})

exec($count) // Signal started and stopped

Complex data types

Record

record method gives access to properties of the object as child signals.

import { record } from 'kida'

const $user = record({ name: 'Dan', age: 30 })
const $name = $user.$name

console.log($name()) // Dan

Also record can be created from another signal.

const $userRecord = record($computedUser)

record method caches child signals in the parent signal. So you can call record multiple times on same signal without performance issues.

import { signal, record } from 'kida'

const $user = signal({ name: 'Dan', age: 30 })
const $name = record($user).$name
const $age = record($user).$age

Deep Record

deepRecord method gives access to nested properties of the object as child signals.

import { deepRecord } from 'kida'

const $user = deepRecord({ name: 'Dan', address: { city: 'Batumi' } })
const $city = $user.$address.$city

console.log($city()) // Batumi

Also deep record can be created from another signal.

const $userRecord = deepRecord($computedUser)

List

atIndex method creates a signal for a specific index of an array.

import { signal, atIndex } from 'kida'

const $users = signal(['Dan', 'John', 'Alice'])
const $firstUser = atIndex($users, 0)

console.log($firstUser()) // Dan

$firstUser('Bob')

console.log($users()) // ['Bob', 'John', 'Alice']

atIndex supports dynamic indexes.

import { signal, atIndex } from 'kida'

const $users = signal(['Dan', 'John', 'Alice'])
const $index = signal(0)
const $user = atIndex($users, $index)

console.log($user()) // Dan

$index(1)

console.log($user()) // John

There are also other methods to work with arrays:

  • updateList($list, fn) - update the value of the list signal using a function.
  • push($list, ...values) - add values to the list signal.
  • pop($list) - removes the last element from a list signal and returns it.
  • shift($list) - removes the first element from a list signal and returns it.
  • unshift($list, ...values) - inserts new elements at the start of an list signal, and returns the new length of the list.
  • getIndex($list, index) - get value at index from the list signal.
  • setIndex($list, index, value) - set value at index in the list signal.
  • deleteIndex($list, index) - delete element at index from the list signal.
  • clearList($list) - clear the list signal.
  • includes($list, value) - check if the list signal includes a value.

Map

atKey method creates a signal for a specific key of an object map.

import { signal, atKey } from 'kida'

const $users = signal({
  2: 'Dan',
  4: 'John',
  6: 'Alice'
})
const $atId4 = atKey($users, 4)

console.log($atId4()) // John

$atId4('Bob')

console.log($atId4()) // { 2: 'Dan', 4: 'Bob', 6: 'Alice' }

atKey supports dynamic indexes.

import { signal, atKey } from 'kida'

const $users = signal({
  2: 'Dan',
  4: 'John',
  6: 'Alice'
})
const $id = signal(4)
const $user = atKey($users, $id)

console.log($user()) // John

$index(6)

console.log($user()) // Alice

There are also other methods to work with object maps:

  • getKey($map, key) - get value by key from the map signal.
  • setKey($map, key, value) - set value by key to the map signal.
  • deleteKey($map, key) - delete item by key from the map signal.
  • clearMap($map) - clear the map signal.
  • has($map, key) - check if the map signal has the key.

Extra signals

Lazy

lazy method creates a signal that is runs initializer function only when it is accessed.

import { lazy } from 'kida'

const $savedString = lazy(() => localStorage.getItem('string') ?? '')

console.log($savedString()) // runs initializer function

External

external method creates a signal that can receive value from external sources.

import { external, onMount, effect } from 'kida'

const $mq = external(($mq) => {
  const mq = window.matchMedia('(min-width: 600px)')
  const setMatched = (mq) => $mq(mq.matches)

  setMatched(mq)

  onMount($mq, () => {
    mq.addEventListener('change', setMatched)

    return () => mq.removeEventListener('change', setMatched)
  })
})

effect(() => {
  console.log('Media query matched:', $mq())
})

Also you can create external signal to save value in external storage.

import { external, effect } from 'kida'

const $locale = external(($locale) => {
  $local(localStorage.getItem('locale') ?? 'en')

  return (newLocale) => {
    localStorage.setItem('locale', newLocale)
    $locale(newLocale)
  }
})

$locale('ru') // will save 'ru' to localStorage

Paced

paced method creates a signal where updates are rate-limited.

import { signal, paced, effect, debounce } from 'kida'

const $search = signal('')
const $searchPaced = paced($search, debounce(300))

effect(() => {
  console.log('Search:', $search())
})

$searchPaced('a')
$searchPaced('ab')
// will log only 'ab' after 300ms

There is also throttle method to limit updates by time interval.

Tasks

addTask can be used to mark all async operations during signal initialization.

import { signal, addTask, onMount } from 'kida'

const tasks = new Set()
const $user = signal(null)

onMount($user, () => {
  addTask(tasks, fetchUser().then(user => $user(user)))
})

You can wait for all ongoing tasks end with allTasks method.

import { allTasks, start } from 'kida'

start($user)
await allTasks(tasks)

Channel

To handle async operations in your app, is better to use channel method. It creates task runner function and signals with loading and error states.

import { channel, signal, onMount } from 'kida'

const tasks = new Set()
const $user = signal(null)
const [userTask, $userLoading, $userError] = channel(tasks)

function fetchUser() {
  return userTask(async (signal) => {
    const response = await fetch('/user', { signal })
    const user = await response.json()

    $user(user)
  })
}

onMount($user, fetchUser)

Task function receives AbortSignal as an argument, so you can run only one task at a time.

SSR

To successfully use signals with SSR, signals should be created for each request. To save tree-shaking capabilities and implement SSR features, mini dependency injection system is implemented in Kida.

Dependency Injection

Dependency injection implementation in Kida has four main methods:

  1. InjectionContext - store for shared dependencies.
  2. run - function to run code within the context.
  3. inject - accepts a factory function and returns dependency from the context or initializes it.
  4. action - helper to bind action functions to the context.
import { InjectionContext, run, inject, action, signal, onMount, Tasks, channel, effect } from 'kida'

function UserChannel() {
  const tasks = inject(Tasks)

  return channel(tasks)
}

function UserSignal() {
  return signal(null)
}

function fetchUserAction() {
  const [userTask] = inject(UserChannel)
  const $user = inject(UserSignal)

  return userTask(async (signal) => {
    const response = await fetch('/user', { signal })
    const user = await response.json()

    $user(user)
  })
}

function UserStore() {
  const [userTask, $userLoading, $userError] = inject(UserChannel)
  const $user = inject(UserSignal)
  const fetchUser = action(fetchUserAction)

  onMount($user, fetchUser)

  return { $user, $userLoading, $userError }
}

const context = new InjectionContext()

run(context, () => {
  const { $user, $userLoading, $userError } = inject(UserStore)

  effect(() => {
    console.log('User:', $user())
    console.log('Loading:', $userLoading())
    console.log('Error:', $userError())
  })
})

[!NOTE] With UI frameworks you will not use InjectionContext and run directly. Integrations with frameworks should include own more convenient API to work with injection context.

Serialization

To serialize signals while SSR, firstly you should mark signals with serializable method to assign serialization key.

import { signal, serializable } from 'kida'

function UserSignal() {
  return serializable('user', signal(null))
}

Then, on SSR server, you can use serialize method wait all tasks and serialize signals.

import { serialize } from 'kida'

const serialized = await serialize(() => {
  const { $user } = inject(UserStore)

  return [$user] // signals to trigger mount event
})

On client side you should provide serialized data to context with Serialized factory.

import { InjectionContext, Serialized, run, inject, effect } from 'kida'

const serialized = {
  user: {
    name: 'John'
  }
}
const context = new InjectionContext(undefined, [[Serialized, serialized]])

run(context, () => {
  const { $user, $userLoading, $userError } = inject(UserStore)

  effect(() => {
    console.log('User:', $user())
    console.log('Loading:', $userLoading())
    console.log('Error:', $userError())
  })
})

[!NOTE] With UI frameworks you will not use InjectionContext and run directly. Integrations with frameworks should include own more convenient API to work with injection context.

Utils

isSignal

isSignal method checks if the value is a signal.

import { isSignal, signal } from 'kida'

isSignal(signal(1)) // true

toSignal

toSignal method converts any value to signal or returns signal as is.

import { toSignal, computed } from 'kida'

const $count = toSignal(0) // WritableSignal<number>
const $double = toSignal(computed(() => $count() * 2)) // ReadableSignal<number>

length

length method creates a signal that tracks the length property of the object.

import { signal, length } from 'kida'

const $users = signal(['Dan', 'John', 'Alice'])
const $count = length($users)

boolean

boolean method creates a signal that converts the value to a boolean.

import { signal, boolean } from 'kida'

const $user = signal(null)
const $hasUser = boolean($user)

get

get method gets the value from the signal or returns the given value.

import { signal, get } from 'kida'

get(signal(1)) // 1
get(1) // 1

Package Sidebar

Install

npm i kida

Weekly Downloads

5

Version

1.0.0-alpha.2

License

MIT

Unpacked Size

105 kB

Total Files

67

Last publish

Collaborators

  • dangreen