JindongJindong

The Reactive Contract

How Jindong decides when to play and which values it reads

The Reactive Contract

Jindong plays on entry by default, as with LaunchedEffect. A change to any key compiles and plays its content, cancelling the previous playback. Three rules describe which values it reads and when it fires.

Rule 1: values in content are frozen at compile time

When a key changes, Jindong compiles the content block into a pattern once and plays it. The compilation runs in a composition that produces the pattern in a single pass and is then torn down, so there is no recomposition. A state value the block reads is read once, at that compile, and does not update on its own afterward.

var level by remember { mutableStateOf(1) }
 
// `level` is read once, when the current key last changed.
Jindong(trigger) {
    RepeatWithIndex(level) { index ->
        Haptic(50.ms)
    }
}

If level changes but trigger does not, the played pattern keeps the level from the last time trigger changed.

Rule 2: keys are the playback trigger

A key change is what fires playback. Put a value in the keys exactly when its change should play. Values that only shape the pattern belong inside the block as parameters.

A key is a value to compare, not a Boolean condition. Both false → true and true → false trigger playback. To emit haptics only on changes to a true condition, suppress entry playback and check the condition inside the content:

Jindong(success, playOnInitialComposition = false) {
    if (success) Haptic(100.ms)
}

This call stays silent on entry even if success is already true. Without the option, a true initial value plays on entry.

The distinction matters most with continuous input. A slider value passed as a key fires a vibration on every drag step:

// Fires on every value change while dragging.
Jindong(sliderValue) {
    Haptic((sliderValue * 100).toInt().ms)
}

The same value as a parameter shapes the next playback without firing on each step:

// Reads sliderValue when `commit` changes; does not fire mid-drag.
Jindong(commit) {
    Haptic((sliderValue * 100).toInt().ms)
}

To make a value both shape the pattern and re-fire when it changes, pass it as a key and read it inside the block:

Jindong(level) {
    RepeatWithIndex(level) { index ->
        Haptic(50.ms, HapticIntensity.Custom(1f - index * 0.1f))
    }
}

Prebuilt patterns follow the same rule. Clip captures its pattern at compile time, so thread the pattern through the keys to re-fire on change: Jindong(pattern) { Clip(pattern) }. See Clip.

Rule 3: cancel-and-restart is best effort

A key change cancels the in-flight playback before starting the new one. The manager serializes this with a state lock, so a fast sequence of key changes cancels in order and the last one wins.

The cancellation itself is best effort. Neither Vibrator.cancel() on Android nor CHHapticPatternPlayer.stop on iOS reports when the hardware has actually stopped, so a few milliseconds of physical overlap between the old and new playback are possible. Patterns that change keys rapidly should not assume a clean cut between them.

Playback ownership

Each default executor owns its current playback session. Coroutine cancellation, a key change, or leaving composition cancels that session's native effect. Starting a replacement cancels the previous session first. Cancelling an old handle later does not cancel the replacement.

JindongProvider releases its previous executor when the platform context changes and releases the current executor when the provider leaves composition. A released executor rejects new playback. HapticManager.release() can be followed by a new initialization.

Silent timelines wait for their logical duration without starting a native effect. Empty timelines finish immediately. Completion remains an estimate; these checks do not measure physical actuator stop time.

Choosing keys

ValueWhere it goesEffect
Should fire playback when it changesKeyEach change plays
Shapes the pattern, read at the next fireParameter in the blockFrozen until a key changes
Should both shape and fireKey and read in the blockEach change plays with the new value

Initial playback

By default, Jindong plays once on entry. This preserves existing behavior, including Jindong(Unit) and calls without keys.

Avoiding entry playback

For feedback tied to user input, set playOnInitialComposition = false. The initial key values then establish a baseline without playback:

Jindong(clickCount, playOnInitialComposition = false) {
    Clip(pattern)
}

This avoids vibrating when clickCount starts at zero and plays when the counter changes. Later key changes compile the current content and play once. Non-key values stay frozen until a key changes. Changing only the option does not compile or play again.

After Jindong leaves and re-enters composition, the initial policy applies again. Changes while it is absent are not replayed. The option uses temporary composition state and does not persist across entries.

With the option set to false, constant or absent keys never trigger playback. Conditional entry still uses the selected policy: omitted or true plays on each entry, while false waits for a later key change.

Future 2.x default

Changing the default to false is deferred to a separate breaking change for 2.x. This addition preserves both the existing function symbols and entry behavior. Use the explicit false option now when entry playback is unwanted.

See also

On this page