A button that only responds to a clean tap and ignores everything else feels wrong the instant a real thumb touches it, because a real touch is never a single clean event — it's a sequence: the finger lands, it may move slightly before lifting, it may leave the button's bounds entirely before returning, or it may get interrupted by an incoming call's system alert mid-press. An app that models touch as "onPress fires or it doesn't" is quietly ignoring the entire sequence a real interaction actually goes through, and it shows up as buttons that fire when they shouldn't, or stay visually "stuck" pressed after a finger has already left.
`Pressable` exists specifically to expose that full sequence — press in, press out, long press, and the specific question of whether the finger is still inside the component's bounds when it lifts — rather than collapsing it down to a single boolean outcome. This exercise builds a press-state state machine using `Pressable`'s actual callback sequence, so the difference between "looks pressed" and "is actually still within the region that should trigger the action" becomes a concrete, testable piece of logic rather than an assumption.
Problem Statement
You're building a custom "swipe-to-confirm" style button used for a high-stakes action — think a payment-confirmation button — where accidentally triggering it from a careless, glancing touch would be a genuinely bad outcome, so the button needs to track press state carefully rather than relying on a naive tap handler. The component needs to distinguish four distinct states as the user interacts with it: idle (no touch), pressed-in-bounds (finger down, currently inside the button), pressed-out-of-bounds (finger down, but has moved outside the button's region without lifting), and the terminal outcome of a lift — which should only count as a confirmed action if the finger was still in-bounds at the moment it lifted.
Your task is to implement a `PressStateMachine` class that models this sequence using the same event vocabulary `Pressable` actually exposes — `onPressIn`, a bounds-check-equivalent event representing the finger moving in or out of the region, and `onPressOut` — and correctly computes whether a given full sequence of events should count as a confirmed press. This mirrors, in pure logic that's easy to test, exactly the reasoning a real `Pressable`-based component has to get right using its real callback props.
Requirements
Implement a `PressStateMachine` class with three methods matching `Pressable`'s real callback shape: `pressIn()` (called when the finger first lands on the component, corresponding to `onPressIn`), `boundsChange(inBounds: boolean)` (called whenever the finger crosses into or out of the component's region while still down, corresponding to the bounds-tracking a real gesture responder performs), and `pressOut()` (called when the finger lifts, corresponding to `onPressOut`). The class must expose a `currentState` getter returning one of `"idle"`, `"pressed-in-bounds"`, or `"pressed-out-of-bounds"`, correctly reflecting the most recent sequence of calls.
`pressOut()` must return a boolean: `true` if the state immediately before the press-out was `"pressed-in-bounds"` (a confirmed action), `false` otherwise, and calling `pressOut()` must also reset `currentState` back to `"idle"`, ready for the next interaction. A sequence of `pressIn()` → `boundsChange(false)` → `boundsChange(true)` → `pressOut()` (the finger drags out and then drags back in before lifting) must correctly return `true`, because the finger was back in-bounds at the moment it actually lifted — the earlier excursion out of bounds does not, by itself, disqualify the interaction.
Starter Code
// press-state-machine.starter.ts — complete this state machine.
// It currently compiles and runs, but always confirms every press
// regardless of bounds — that's the bug the TODOs below need to fix.
type PressState = "idle" | "pressed-in-bounds" | "pressed-out-of-bounds";
class PressStateMachine {
private state: PressState = "idle";
get currentState(): PressState {
return this.state;
}
pressIn(): void {
// TODO 1: a finger landing should move the state to "pressed-in-bounds".
this.state = "pressed-in-bounds";
}
boundsChange(inBounds: boolean): void {
// TODO 2: while a finger is down, crossing the component's bounds
// should move the state between "pressed-in-bounds" and
// "pressed-out-of-bounds". Currently this does nothing at all.
}
pressOut(): boolean {
// TODO 3: should only return true if the state was
// "pressed-in-bounds" immediately before this call, and must reset
// state back to "idle" regardless of the outcome. Currently this
// always returns true without checking the actual state at all.
this.state = "idle";
return true;
}
}
const button = new PressStateMachine();
button.pressIn();
button.boundsChange(false); // finger drags outside the button
const confirmedWhileOutOfBounds = button.pressOut(); // finger lifts while still outside
console.log("Starter result (should be false — finger lifted out of bounds):", confirmedWhileOutOfBounds);
Running the starter as-is prints `true`, which is exactly the bug this exercise is built around: the finger dragged outside the button and lifted while still outside, which should not count as a confirmed press for a payment-confirmation button, but the placeholder `pressOut()` returns `true` unconditionally without checking `boundsChange` at all, and `boundsChange` itself doesn't update state yet. The three TODOs mark exactly where the real state-tracking logic needs to go.
Hints & Common Pitfalls
`boundsChange(inBounds)` should only actually change state while the finger is down — it should never move `currentState` out of `"idle"`, because a `boundsChange` call arriving when nothing is actually pressed (which shouldn't happen in a correctly wired real component, but is worth guarding against in the state machine itself) shouldn't spuriously start a press. Guard the state transition on the current state already being one of the two "pressed" states before applying a bounds change.
A subtler pitfall is implementing `pressOut()` by checking the `inBounds` argument that was most recently passed to `boundsChange`, stored as a separate boolean flag, rather than checking `this.state` directly — this works for the simple cases but is redundant with `currentState` and creates two sources of truth that can drift out of sync if a future change updates one and not the other; the cleaner, more maintainable implementation checks `this.state === "pressed-in-bounds"` directly, since that's already the single source of truth `boundsChange` should be maintaining.
A common mistake is implementing `boundsChange(false)` by immediately setting state all the way to `"idle"` rather than to `"pressed-out-of-bounds"`. The symptom is that a finger dragging out of the button and then dragging back in before lifting incorrectly fails to confirm, because once state hit `"idle"`, the subsequent `boundsChange(true)` had no "pressed" state to return to and the machine effectively treated the interaction as already over. The root cause is conflating "outside the button's bounds" with "no longer pressed at all" — they are genuinely different states, since the finger is still down, just not currently over the button. The fix is a real third state, `"pressed-out-of-bounds"`, distinct from both `"idle"` and `"pressed-in-bounds"`, that a subsequent `boundsChange(true)` can transition back out of, exactly as this exercise's Requirements specify.
Reference Solution
// press-state-machine.solution.ts — a complete, correctly ordered
// implementation using a single source of truth for the press state.
type PressState = "idle" | "pressed-in-bounds" | "pressed-out-of-bounds";
class PressStateMachine {
private state: PressState = "idle";
get currentState(): PressState {
return this.state;
}
pressIn(): void {
this.state = "pressed-in-bounds";
}
boundsChange(inBounds: boolean): void {
// Only meaningful while a finger is actually down — guards against a
// stray bounds event when nothing is pressed at all.
if (this.state === "idle") return;
this.state = inBounds ? "pressed-in-bounds" : "pressed-out-of-bounds";
}
pressOut(): boolean {
// Single source of truth: check the state itself, not a duplicated flag.
const confirmed = this.state === "pressed-in-bounds";
this.state = "idle";
return confirmed;
}
}
function runSequence(label: string, actions: (m: PressStateMachine) => boolean): void {
const machine = new PressStateMachine();
const result = actions(machine);
console.log(`${label}: confirmed=${result}`);
}
runSequence("Clean tap, no drag", (m) => {
m.pressIn();
return m.pressOut();
});
runSequence("Drag out, lift while out (should NOT confirm)", (m) => {
m.pressIn();
m.boundsChange(false);
return m.pressOut();
});
runSequence("Drag out then back in, lift while back in (SHOULD confirm)", (m) => {
m.pressIn();
m.boundsChange(false);
m.boundsChange(true);
return m.pressOut();
});
The three logged sequences exercise exactly the cases the Requirements section specified: a clean tap confirms, a drag-out-and-lift-while-out does not confirm, and a drag-out-then-back-in-then-lift does confirm, because `pressOut()` checks the current state at the moment it's actually called, not any earlier state in the sequence. The `boundsChange` guard against `"idle"` state is a defensive measure that doesn't affect the three required scenarios but prevents a subtle bug if a bounds event ever arrives before a press-in event does, which a real gesture responder implementation should never allow but a state machine written defensively shouldn't assume.
Mapped onto a real `Pressable`, `pressIn()` corresponds to the `onPressIn` callback, `pressOut()`'s confirmed/not-confirmed result corresponds to deciding whether to actually fire the app's real action inside `onPressOut`, and `boundsChange` corresponds to the bounds tracking `Pressable` performs internally and exposes indirectly through its press-state styling callback — a production component wouldn't need to hand-roll this exact state machine since `Pressable` already handles the equivalent logic, but understanding the sequence it's handling is what makes its behavior predictable rather than mysterious.
Testing Your Solution
// press-state-machine.test.ts — a small table-driven test suite covering
// the required cases plus a couple of deliberately awkward edge cases.
type PressState = "idle" | "pressed-in-bounds" | "pressed-out-of-bounds";
class PressStateMachine {
private state: PressState = "idle";
get currentState(): PressState {
return this.state;
}
pressIn(): void {
this.state = "pressed-in-bounds";
}
boundsChange(inBounds: boolean): void {
if (this.state === "idle") return;
this.state = inBounds ? "pressed-in-bounds" : "pressed-out-of-bounds";
}
pressOut(): boolean {
const confirmed = this.state === "pressed-in-bounds";
this.state = "idle";
return confirmed;
}
}
interface Case {
label: string;
run: (m: PressStateMachine) => boolean;
expected: boolean;
}
const cases: Case[] = [
{ label: "clean tap", run: (m) => { m.pressIn(); return m.pressOut(); }, expected: true },
{ label: "drag out, lift out", run: (m) => { m.pressIn(); m.boundsChange(false); return m.pressOut(); }, expected: false },
{ label: "drag out then back in, lift in", run: (m) => { m.pressIn(); m.boundsChange(false); m.boundsChange(true); return m.pressOut(); }, expected: true },
{ label: "drag out, back in, out again, lift out", run: (m) => { m.pressIn(); m.boundsChange(false); m.boundsChange(true); m.boundsChange(false); return m.pressOut(); }, expected: false },
{ label: "redundant boundsChange(true) while already in bounds", run: (m) => { m.pressIn(); m.boundsChange(true); m.boundsChange(true); return m.pressOut(); }, expected: true },
{ label: "boundsChange arriving with no active press is ignored", run: (m) => { m.boundsChange(false); m.pressIn(); return m.pressOut(); }, expected: true },
];
let failures = 0;
for (const c of cases) {
const machine = new PressStateMachine();
const actual = c.run(machine);
const stateAfter = machine.currentState;
const pass = actual === c.expected && stateAfter === "idle";
if (!pass) failures += 1;
console.log(`[${pass ? "PASS" : "FAIL"}] ${c.label}: confirmed=${actual} (expected ${c.expected}), stateAfter=${stateAfter}`);
}
console.log(`\n${cases.length - failures}/${cases.length} cases passed.`);
if (failures > 0) process.exit(1);
The fifth case — a redundant `boundsChange(true)` while already in bounds — checks that the state machine is idempotent for a no-op bounds update, which a real gesture responder can plausibly fire more than once in a row as it continuously tracks finger position; the state machine should not treat a repeated identical bounds update as anything unusual. The sixth case checks the defensive guard from the Reference Solution directly: a stray `boundsChange` call before any `pressIn()` should be silently ignored rather than corrupting a subsequent, legitimate press sequence.
Every case in this suite also checks `stateAfter === "idle"`, not just the returned boolean, because a `pressOut()` that returns the correct confirmed value but fails to reset state back to `"idle"` would leave the machine in a corrupted condition for the very next interaction — a bug that a test checking only the return value would miss entirely, which is exactly the kind of gap a thorough test suite is supposed to close.