/ ink / hooks / use-input.ts
use-input.ts
 1  import { useEffect, useLayoutEffect } from 'react'
 2  import { useEventCallback } from 'usehooks-ts'
 3  import type { InputEvent, Key } from '../events/input-event.js'
 4  import useStdin from './use-stdin.js'
 5  
 6  type Handler = (input: string, key: Key, event: InputEvent) => void
 7  
 8  type Options = {
 9    /**
10     * Enable or disable capturing of user input.
11     * Useful when there are multiple useInput hooks used at once to avoid handling the same input several times.
12     *
13     * @default true
14     */
15    isActive?: boolean
16  }
17  
18  /**
19   * This hook is used for handling user input.
20   * It's a more convenient alternative to using `StdinContext` and listening to `data` events.
21   * The callback you pass to `useInput` is called for each character when user enters any input.
22   * However, if user pastes text and it's more than one character, the callback will be called only once and the whole string will be passed as `input`.
23   *
24   * ```
25   * import {useInput} from 'ink';
26   *
27   * const UserInput = () => {
28   *   useInput((input, key) => {
29   *     if (input === 'q') {
30   *       // Exit program
31   *     }
32   *
33   *     if (key.leftArrow) {
34   *       // Left arrow key pressed
35   *     }
36   *   });
37   *
38   *   return …
39   * };
40   * ```
41   */
42  const useInput = (inputHandler: Handler, options: Options = {}) => {
43    const { setRawMode, internal_exitOnCtrlC, internal_eventEmitter } = useStdin()
44  
45    // useLayoutEffect (not useEffect) so that raw mode is enabled synchronously
46    // during React's commit phase, before render() returns. With useEffect, raw
47    // mode setup is deferred to the next event loop tick via React's scheduler,
48    // leaving the terminal in cooked mode — keystrokes echo and the cursor is
49    // visible until the effect fires.
50    useLayoutEffect(() => {
51      if (options.isActive === false) {
52        return
53      }
54  
55      setRawMode(true)
56  
57      return () => {
58        setRawMode(false)
59      }
60    }, [options.isActive, setRawMode])
61  
62    // Register the listener once on mount so its slot in the EventEmitter's
63    // listener array is stable. If isActive were in the effect's deps, the
64    // listener would re-append on false→true, moving it behind listeners
65    // that registered while it was inactive — breaking
66    // stopImmediatePropagation() ordering. useEventCallback keeps the
67    // reference stable while reading latest isActive/inputHandler from
68    // closure (it syncs via useLayoutEffect, so it's compiler-safe).
69    const handleData = useEventCallback((event: InputEvent) => {
70      if (options.isActive === false) {
71        return
72      }
73      const { input, key } = event
74  
75      // If app is not supposed to exit on Ctrl+C, then let input listener handle it
76      // Note: discreteUpdates is called at the App level when emitting events,
77      // so all listeners are already within a high-priority update context.
78      if (!(input === 'c' && key.ctrl) || !internal_exitOnCtrlC) {
79        inputHandler(input, key, event)
80      }
81    })
82  
83    useEffect(() => {
84      internal_eventEmitter?.on('input', handleData)
85  
86      return () => {
87        internal_eventEmitter?.removeListener('input', handleData)
88      }
89    }, [internal_eventEmitter, handleData])
90  }
91  
92  export default useInput