Search code examples
javascriptreactjstypescriptrust-ink

Cannot get synced state in React INK when using "useStdin" hook with rawMode


I'm using ink package, and I'm building the following component:

MultiSelect.tsx file:

import React, { useEffect, useState } from 'react';
import { useStdin } from 'ink';

import type { ISelectItem, ISelectItemSelection } from './interfaces/select-item';
import { ARROW_DOWN, ARROW_UP, ENTER, SPACE } from './constants/input';

import MultiSelectView from './MultiSelect.view';

interface IProps {
    readonly items: ISelectItem[];
    readonly onSubmit: (selectedItems: string[]) => void;
}

const MultiSelect: React.FC<IProps> = (props: React.PropsWithChildren<IProps>) => {
    const { stdin, setRawMode } = useStdin();

    const [itemsSelectionState, setItemsSelectionState] = useState<ISelectItemSelection[]>(
        props.items.map((item, index) => ({ ...item, selected: false, isHighlighted: index === 0 })),
    );

    const stdinInputHandler = (data: unknown) => {
        const rawData = String(data);

        if (rawData === ARROW_DOWN) {
            setItemsSelectionState((prev) => {
                const highlightedIndex = prev.findIndex((item) => item.isHighlighted);

                if (highlightedIndex === -1) {
                    return prev;
                }

                const clonedPrev = structuredClone(prev);

                clonedPrev[highlightedIndex]!.isHighlighted = false;

                if (highlightedIndex === prev.length - 1) {
                    clonedPrev[0]!.isHighlighted = true;
                } else {
                    clonedPrev[highlightedIndex + 1]!.isHighlighted = true;
                }

                return clonedPrev;
            });
        }

        if (rawData === ARROW_UP) {
            setItemsSelectionState((prev) => {
                const highlightedIndex = prev.findIndex((item) => item.isHighlighted);

                if (highlightedIndex === -1) {
                    return prev;
                }

                const clonedPrev = structuredClone(prev);

                clonedPrev[highlightedIndex]!.isHighlighted = false;

                if (highlightedIndex === 0) {
                    clonedPrev[prev.length - 1]!.isHighlighted = true;
                } else {
                    clonedPrev[highlightedIndex - 1]!.isHighlighted = true;
                }

                return clonedPrev;
            });
        }

        if (rawData === SPACE) {
            setItemsSelectionState((prev) => {
                const highlightedIndex = prev.findIndex((item) => item.isHighlighted);

                if (highlightedIndex === -1) {
                    return prev;
                }

                const clonedPrev = structuredClone(prev);

                clonedPrev[highlightedIndex]!.selected = !prev[highlightedIndex]!.selected;

                return clonedPrev;
            });
        }

        if (rawData === ENTER) {
            const selectedItemsValues = itemsSelectionState
                .filter((item) => item.selected)
                .map((item) => item.value);

            props.onSubmit(selectedItemsValues);
        }
    };

    useEffect(() => {
        setRawMode(true);
        stdin?.on('data', stdinInputHandler);

        return () => {
            stdin?.removeListener('data', stdinInputHandler);
            setRawMode(false);
        };
    }, []);

    return <MultiSelectView itemsSelection={itemsSelectionState} />;
};

export default MultiSelect;

MultiSelect.view.tsx file:

import { Box, Text } from 'ink';
import React from 'react';

import type { ISelectItemSelection } from './interfaces/select-item';

interface IProps {
    readonly itemsSelection: ISelectItemSelection[];
}

const MultiSelectView: React.FC<IProps> = (props: React.PropsWithChildren<IProps>) => {
    return (
        <Box display="flex" flexDirection="column">
            {props.itemsSelection.map((item) => (
                <Box key={item.value} display="flex" flexDirection="row">
                    <Text color="blue">{item.isHighlighted ? '❯' : ' '}</Text>
                    <Text color="magenta">
                        &nbsp;
                        {item.selected ? '◉' : '◯'}
                        &nbsp;
                    </Text>
                    <Text color="white" bold={item.selected}>
                        {item.label}
                    </Text>
                </Box>
            ))}
        </Box>
    );
};

export default MultiSelectView;

Then, when I use this component in my code:

        const onSubmitItems = (items: string[]) => {
            console.log(items);
        };

        render(<MultiSelect items={items} onSubmit={onSubmitItems} />);

the render function is imported from ink and items is something like [{value: 'x', label: 'x'}, {value:'y', label:'y'}]

When I hit the enter key, Then onSubmitItems is triggered, but it outputs empty list, although I did select some items...

For example, in this output: enter image description here

I picked 3 items, but output is still empty list. And it does seem like the state changes, so why I don't get the updated state?


Solution

  • You can resolve the issue by using useCallback:

    const stdinInputHandler = useCallback((data: Buffer) => {
            const rawData = String(data);
    
            if (rawData === ARROW_DOWN) {
                setItemsSelectionState((prev) => {
                    const highlightedIndex = prev.findIndex((item) => item.isHighlighted);
    
                    if (highlightedIndex === -1) {
                        return prev;
                    }
    
                    const clonedPrev = structuredClone(prev);
    
                    clonedPrev[highlightedIndex]!.isHighlighted = false;
    
                    if (highlightedIndex === prev.length - 1) {
                        clonedPrev[0]!.isHighlighted = true;
                    } else {
                        clonedPrev[highlightedIndex + 1]!.isHighlighted = true;
                    }
    
                    return clonedPrev;
                });
            }
    
            if (rawData === ARROW_UP) {
                setItemsSelectionState((prev) => {
                    const highlightedIndex = prev.findIndex((item) => item.isHighlighted);
    
                    if (highlightedIndex === -1) {
                        return prev;
                    }
    
                    const clonedPrev = structuredClone(prev);
    
                    clonedPrev[highlightedIndex]!.isHighlighted = false;
    
                    if (highlightedIndex === 0) {
                        clonedPrev[prev.length - 1]!.isHighlighted = true;
                    } else {
                        clonedPrev[highlightedIndex - 1]!.isHighlighted = true;
                    }
    
                    return clonedPrev;
                });
            }
    
            if (rawData === SPACE) {
                setItemsSelectionState((prev) => {
                    const highlightedIndex = prev.findIndex((item) => item.isHighlighted);
    
                    if (highlightedIndex === -1) {
                        return prev;
                    }
    
                    const clonedPrev = structuredClone(prev);
    
                    clonedPrev[highlightedIndex]!.selected = !prev[highlightedIndex]!.selected;
    
                    return clonedPrev;
                });
            }
    
            if (rawData === ENTER) {
                const selectedItemsValues = itemsSelectionState
                    .filter((item) => item.selected)
                    .map((item) => item.value);
    
                props.onSubmit(selectedItemsValues);
            }
        }, [itemsSelectionState]);
    

    Then, in your useEffect:

      useEffect(() => {
            setRawMode(true);
            stdin?.on('data', stdinInputHandler);
    
            return () => {
                stdin?.removeListener('data', stdinInputHandler);
                setRawMode(false);
            };
        }, [stdinInputHandler]);
    

    now useEffect will re-register the input handler with new functions locals up-to-date with the state