Read this in other languages: English | 简体中文
- Framework Agnostic - Works with Vue, React, Angular, Svelte, Node.js, and vanilla JavaScript
- TypeScript Support - Full type definitions included
- Multiple Input Types - Supports getter functions, Subscribable objects, RefLike objects, and plain values
- Deep Comparison - Configurable depth for object/array comparison
- Timeout Support - Built-in timeout handling with optional error throwing
- Tree-shakeable - Only bundle what you use
- Zero Dependencies - Only
js-coolas a lightweight runtime dependency - Well Tested - 227+ test cases with comprehensive coverage
- Vue 3 Example (CodeSandbox)
- Vue 2 Example (CodeSandbox)
- React Example (CodeSandbox)
- Angular Example (CodeSandbox)
- Svelte Example (CodeSandbox)
- Node.js Example
- Vanilla JS Example
# use pnpm
$ pnpm install untiljs
# use npm
$ npm install untiljs --save
# use yarn
$ yarn add untiljsimport until from 'untiljs'
// Basic usage with getter function
let value = 1
setTimeout(() => {
value = 2
}, 1000)
await until(() => value).toBe(2)
console.log('Value is now 2!')A WatchSource<T> can be one of:
| Type | Example | Description |
|---|---|---|
| Getter Function | () => value |
A function that returns the current value |
| Subscribable | { value, subscribe } |
An object with value property and subscribe method |
| RefLike | { value } |
An object with value property (e.g., Vue ref) |
| Plain Value | 5, 'hello' |
A static value |
interface Subscribable<T> {
readonly value: T
subscribe(callback: (value: T) => void): () => void
}| Method | Description |
|---|---|
toBe(value, options?) |
Wait until the source equals the given value |
toMatch(condition, options?) |
Wait until the condition returns true |
toBeTruthy(options?) |
Wait until the value is truthy |
toBeNull(options?) |
Wait until the value is null |
toBeUndefined(options?) |
Wait until the value is undefined |
toBeNaN(options?) |
Wait until the value is NaN |
changed(options?) |
Wait until the value changes |
changedTimes(n, options?) |
Wait until the value changes n times |
toContains(value, options?) |
Wait until array contains value (arrays only) |
not.* |
Inverse of any method above |
interface UntilToMatchOptions {
/** Timeout in milliseconds (0 = never timeout) */
timeout?: number
/** Reject promise on timeout (default: false) */
throwOnTimeout?: boolean
/** Deep comparison depth (true = unlimited, number = specific depth) */
deep?: boolean | number
}import until from 'untiljs'
// Wait for value to equal something
let count = 0
setTimeout(() => {
count = 5
}, 1000)
await until(() => count).toBe(5)
// Wait for custom condition
await until(() => count).toMatch(v => v > 3)
// Wait for truthy value
let data = null
setTimeout(() => {
data = { name: 'John' }
}, 500)
await until(() => data).toBeTruthy()
// Wait for value to change
let value = 'initial'
setTimeout(() => {
value = 'changed'
}, 1000)
await until(() => value).changed()
// Wait for multiple changes
await until(() => value).changedTimes(3)Vue refs work directly because they are RefLike objects:
import { ref, computed } from 'vue'
import until from 'untiljs'
const count = ref(0)
const doubled = computed(() => count.value * 2)
// Method 1: Pass ref directly (recommended for Vue!)
async function waitForValue() {
await until(count).toBe(5)
console.log('Count reached 5!')
}
// Method 2: Use getter function
async function waitForValueGetter() {
await until(() => count.value).toBe(5)
console.log('Count reached 5!')
}
// Watch computed values
async function waitForDoubled() {
await until(doubled).toBe(10)
console.log('Doubled value reached 10!')
}
// Deep comparison for objects
const user = ref({ profile: { name: '' } })
setTimeout(() => {
user.value = { profile: { name: 'John' } }
}, 1000)
await until(user).toMatch(v => v.profile.name === 'John', { deep: true })Important: Due to React's closure behavior, using
until(() => stateValue)directly won't work properly. UseuseRefor a custom hook instead.
// This won't detect changes due to React's closure behavior
const [value, setValue] = useState(0)
await until(() => value).toBe(5) // ❌ Always sees old valueimport { useCallback, useRef, useState } from 'react'
import until from 'untiljs'
// Custom hook for untiljs
function useUntil<T>(initialValue: T) {
const ref = useRef(initialValue)
const [value, setValue] = useState(initialValue)
const refLike = useRef({
get value() {
return ref.current
},
set value(newValue: T) {
ref.current = newValue
setValue(newValue)
}
})
const setValueAndRef = useCallback((newValue: T) => {
ref.current = newValue
setValue(newValue)
}, [])
return {
value,
setValue: setValueAndRef,
until: () => until(refLike.current)
}
}
// Usage
function MyComponent() {
const data = useUntil(0)
const handleClick = async () => {
data.setValue(0)
setTimeout(() => data.setValue(5), 1000)
await data.until().toBe(5) // ✅ Works correctly!
console.log('Value is now 5!')
}
return <button onClick={handleClick}>Test</button>
}function createSubscribable<T>(initialValue: T) {
let value = initialValue
const listeners = new Set<(value: T) => void>()
return {
get value() {
return value
},
set value(newValue: T) {
value = newValue
listeners.forEach(l => l(value))
},
subscribe(callback: (value: T) => void) {
listeners.add(callback)
callback(value)
return () => listeners.delete(callback)
}
}
}
// Usage with useEffect
const store = createSubscribable(0)
await until(store).toBe(5) // ✅ Most efficient!untiljs v2.1+ provides a built-in createStore function for React:
import { createStore } from 'untiljs'
import until from 'untiljs'
// Create store outside component or in useRef
const store = createStore(0)
function MyComponent() {
const [value, setValue] = useState(store.value)
useEffect(() => store.subscribe(setValue), [])
const handleClick = async () => {
store.value = 5
await until(store).toBe(5) // ✅ Clean and efficient!
}
return <button onClick={handleClick}>Test</button>
}Angular 19+ signals work with getter functions:
import { Component, signal } from '@angular/core'
import until from 'untiljs'
@Component({
selector: 'app-example',
template: `
<p>Count: {{ count() }}</p>
<button (click)="waitForValue()">Wait for 5</button>
`
})
export class ExampleComponent {
count = signal(0)
async waitForValue() {
this.count.set(0)
setTimeout(() => this.count.set(5), 1000)
// Use getter function with signals
await until(() => this.count()).toBe(5)
console.log('Count reached 5!')
}
}import { Component } from '@angular/core'
import { createStore } from 'untiljs'
import until from 'untiljs'
@Component({
selector: 'app-example',
template: `<button (click)="test()">Test</button>`
})
export class ExampleComponent {
private store = createStore(0)
async test() {
this.store.value = 0
setTimeout(() => this.store.value = 5, 1000)
// Store works directly with until
await until(this.store).toBe(5)
}
}Svelte 5 runes work with getter functions:
<script>
import until from 'untiljs'
let count = $state(0)
async function waitForValue() {
count = 0
setTimeout(() => count = 5, 1000)
// Use getter function with $state
await until(() => count).toBe(5)
console.log('Count reached 5!')
}
</script>
<p>Count: {count}</p>
<button onclick={waitForValue}>Wait for 5</button><script>
import { createStore } from 'untiljs'
import until from 'untiljs'
const store = createStore(0)
let storeValue = $state(store.value)
// Subscribe to changes
$effect(() => {
return store.subscribe(value => storeValue = value)
})
async function test() {
store.value = 0
setTimeout(() => store.value = 5, 1000)
await until(store).toBe(5)
}
</script>Vue 2.7+ supports Composition API natively:
<script>
import { ref } from 'vue'
import until from 'untiljs'
export default {
setup() {
const count = ref(0)
const waitForValue = async () => {
count.value = 0
setTimeout(() => count.value = 5, 1000)
// Use getter function with ref
await until(() => count.value).toBe(5)
console.log('Count reached 5!')
}
return { count, waitForValue }
}
}
</script>
<template>
<p>Count: {{ count }}</p>
<button @click="waitForValue">Wait for 5</button>
</template>Note: Vue 2.6 and below require
@vue/composition-apiplugin. Import from@vue/composition-apiinstead ofvue.
import { BehaviorSubject } from 'rxjs'
import until from 'untiljs'
// Convert BehaviorSubject to Subscribable
const subject = new BehaviorSubject(1)
const subscribable = {
get value() {
return subject.value
},
subscribe(callback: (value: number) => void) {
const subscription = subject.subscribe(callback)
return () => subscription.unsubscribe()
}
}
await until(subscribable).toBe(2)
// Or use getter function
await until(() => subject.value).toBe(2)import until from 'untiljs'
import { EventEmitter } from 'events'
// Wait for event-based state changes
const emitter = new EventEmitter()
let status = 'pending'
emitter.on('ready', () => {
status = 'ready'
})
setTimeout(() => emitter.emit('ready'), 1000)
await until(() => status).toBe('ready')
// Wait for file changes (with fs.watch)
import fs from 'fs'
import { readFile } from 'fs/promises'
let fileContent = await readFile('./data.txt', 'utf-8')
const watcher = fs.watch('./data.txt', async () => {
fileContent = await readFile('./data.txt', 'utf-8')
})
await until(() => fileContent).toMatch(content => content.includes('target'))
watcher.close()import until from 'untiljs'
// Wait for array to contain value
let items = ['apple', 'banana']
setTimeout(() => {
items.push('orange')
}, 500)
await until(() => items).toContains('orange')
// Arrays also support all value methods
let numbers = [1, 2, 3]
setTimeout(() => {
numbers = [1, 2, 3, 4, 5]
}, 500)
await until(() => numbers).toBe([1, 2, 3, 4, 5], { deep: true })
await until(() => numbers).toMatch(arr => arr.length >= 5)import until from 'untiljs'
// Timeout without throwOnTimeout - returns current value
let value = 0
const result = await until(() => value).toBe(5, { timeout: 1000 })
console.log(result) // 0 (current value after timeout)
// Timeout with throwOnTimeout - rejects promise
try {
await until(() => value).toBe(5, { timeout: 1000, throwOnTimeout: true })
} catch (error) {
console.error('Timeout!', error)
}import until from 'untiljs'
// Wait until value is NOT 5
let value = 5
setTimeout(() => {
value = 10
}, 500)
await until(() => value).not.toBe(5)
console.log(value) // 10
// Wait until value is NOT null
let data = null
setTimeout(() => {
data = 'loaded'
}, 500)
await until(() => data).not.toBeNull()
console.log(data) // 'loaded'import until from 'untiljs'
// Compare nested objects
let config = { server: { port: 3000 } }
setTimeout(() => {
config = { server: { port: 8080 } }
}, 500)
await until(() => config).toBe({ server: { port: 8080 } }, { deep: true })
// Limit comparison depth
await until(() => config).toBe(
{ server: { port: 8080 } },
{ deep: 2 } // Compare up to 2 levels deep
)- No longer requires
@vue/reactivity- The library is now framework-agnostic - Vue refs work directly - Pass
refdirectly or use() => ref.value - Removed
flushoption - This was Vue-specific and has no generic equivalent deepoption now acceptsboolean | number- More flexible depth control
// v1.x
import { ref } from 'vue'
import until from 'untiljs'
const count = ref(0)
await until(count).toBe(5)
// v2.x - Same code still works!
import { ref } from 'vue'
import until from 'untiljs'
const count = ref(0)
await until(count).toBe(5) // Direct ref usage still supported
// OR
await until(() => count.value).toBe(5) // Getter function also works| v1.x | v2.x |
|---|---|
until(ref) |
until(ref) (still works!) or until(() => ref.value) |
until(ref).toBe(otherRef) |
until(ref).toBe(otherRef.value) |
{ flush: 'sync' } |
(removed - uses requestAnimationFrame/setImmediate) |
{ deep: true } |
{ deep: true } (unchanged) or { deep: 5 } for depth limit |
<script src="https://unpkg.com/untiljs@latest/dist/index.iife.min.js"></script>
<script>
let value = 0
setTimeout(() => {
value = 5
}, 1000)
until(() => value)
.toBe(5)
.then(() => {
console.log('Value is 5!')
})
</script>| Browser | Version |
|---|---|
| Chrome | Latest 2 versions |
| Firefox | Latest 2 versions |
| Safari | Latest 2 versions |
| Edge | Latest 2 versions |
| Node.js | >= 16 |
Please open an issue here.