Feedback
import { useState, useEffect, useCallback } from 'react';import { HotTable, HotColumn, EditorComponent } from '@handsontable/react-wrapper';import { registerAllModules } from 'handsontable/registry';
// register Handsontable's modulesregisterAllModules();
/* start:skip-in-preview */
export const data = [ { feature: 'Dark Mode', category: 'UI', priority: 'High', feedback: '👍', votes: 124, status: 'Planned' }, { feature: 'Bulk Edit', category: 'Core', priority: 'High', feedback: '👍', votes: 98, status: 'In Progress' }, { feature: 'AI Suggestions', category: 'Beta', priority: 'Medium', feedback: '🤷', votes: 45, status: 'Research' }, { feature: 'Offline Mode', category: 'Infra', priority: 'Low', feedback: '👎', votes: 12, status: 'Backlog' },];
/* end:skip-in-preview */
export const FeedbackEditor = () => { const [config, setConfig] = useState(['👍', '👎', '🤷']); const [shortcuts, setShortcuts] = useState([]); const onPrepare = (_row, _column, _prop, _TD, _originalValue, cellProperties) => { setConfig(cellProperties.config); };
useEffect(() => { setShortcuts([ { keys: [['ArrowRight'], ['Tab']], callback: ({ value, setValue }, _event) => { setValue(getNextValue(value));
return false; }, }, { keys: [['ArrowLeft']], callback: ({ value, setValue }, _event) => { setValue(getPrevValue(value)); }, }, ]); }, [config]);
const getNextValue = useCallback( (value) => { const index = config.indexOf(value);
return index === config.length - 1 ? config[0] : config[index + 1]; }, [config] );
const getPrevValue = useCallback( (value) => { const index = config.indexOf(value);
return index === 0 ? config[config.length - 1] : config[index - 1]; }, [config] );
return ( <EditorComponent onPrepare={onPrepare} shortcuts={shortcuts}> {({ value, setValue, finishEditing }) => ( <> <div className="feedback-editor"> {config.map((item, _index, _array) => ( <button className={value === item ? 'active' : ''} key={item} onClick={() => { setValue(item); finishEditing(); }} style={{ width: `${100 / _array.length}%`, }} > {item} </button> ))} </div> </> )} </EditorComponent> );};
const ExampleComponent = () => { return ( <HotTable autoRowSize={true} rowHeaders={true} autoWrapRow={true} licenseKey="non-commercial-and-evaluation" height="auto" width="100%" data={data} colHeaders={['Feature', 'Category', 'Priority', 'Feedback', 'Votes', 'Status']} headerClassName="htLeft" > <HotColumn data="feature" type="text" width={200} /> <HotColumn data="category" type="text" width={90} /> <HotColumn data="priority" type="text" width={100} /> <HotColumn data="feedback" type="text" width={100} editor={FeedbackEditor} config={['👍', '👎', '🤷']} /> <HotColumn data="votes" type="numeric" width={60} /> <HotColumn data="status" type="text" width={120} /> </HotTable> );};
export default ExampleComponent;import { useState, useEffect, ComponentProps, useCallback } from 'react';import { HotTable, HotColumn, EditorComponent } from '@handsontable/react-wrapper';import { registerAllModules } from 'handsontable/registry';
// register Handsontable's modulesregisterAllModules();
/* start:skip-in-preview */
export const data = [ { feature: 'Dark Mode', category: 'UI', priority: 'High', feedback: '👍', votes: 124, status: 'Planned' }, { feature: 'Bulk Edit', category: 'Core', priority: 'High', feedback: '👍', votes: 98, status: 'In Progress' }, { feature: 'AI Suggestions', category: 'Beta', priority: 'Medium', feedback: '🤷', votes: 45, status: 'Research' }, { feature: 'Offline Mode', category: 'Infra', priority: 'Low', feedback: '👎', votes: 12, status: 'Backlog' },];
/* end:skip-in-preview */
type EditorComponentProps = ComponentProps<typeof EditorComponent<string>>;
export const FeedbackEditor = () => { const [config, setConfig] = useState<string[]>(['👍', '👎', '🤷']); const [shortcuts, setShortcuts] = useState<EditorComponentProps['shortcuts']>([]); const onPrepare: EditorComponentProps['onPrepare'] = (_row, _column, _prop, _TD, _originalValue, cellProperties) => { setConfig(cellProperties.config as string[]); };
useEffect(() => { setShortcuts([ { keys: [['ArrowRight'], ['Tab']], callback: ({value, setValue}, _event) => { setValue(getNextValue(value)); return false; } }, { keys: [['ArrowLeft']], callback: ({value, setValue}, _event) => { setValue(getPrevValue(value)); } } ]) }, [config]);
const getNextValue = useCallback((value: string) => { const index = config.indexOf(value); return index === config.length - 1 ? config[0] : config[index + 1]; }, [config]);
const getPrevValue = useCallback((value: string) => { const index = config.indexOf(value); return index === 0 ? config[config.length - 1] : config[index - 1]; }, [config]);
return ( <EditorComponent<string> onPrepare={onPrepare} shortcuts={shortcuts}> {({ value, setValue, finishEditing }) => ( <> <div className="feedback-editor"> {config.map((item, _index, _array) => ( <button className={value === item ? 'active' : ''} key={item} onClick={() => { setValue(item); finishEditing(); }} style={{ width: 100 / _array.length + '%' }} > {item} </button> ))} </div> </> )} </EditorComponent> );};
const ExampleComponent = () => { return ( <HotTable autoRowSize={true} rowHeaders={true} autoWrapRow={true} licenseKey="non-commercial-and-evaluation" height="auto" width="100%" data={data} colHeaders={['Feature', 'Category', 'Priority', 'Feedback', 'Votes', 'Status']} headerClassName="htLeft" > <HotColumn data="feature" type="text" width={200} /> <HotColumn data="category" type="text" width={90} /> <HotColumn data="priority" type="text" width={100} /> <HotColumn data="feedback" type="text" width={100} editor={FeedbackEditor} config={['👍', '👎', '🤷']} /> <HotColumn data="votes" type="numeric" width={60} /> <HotColumn data="status" type="text" width={120} /> </HotTable> );};
export default ExampleComponent;.feedback-editor { display: flex; gap: var(--ht-gap, 4px); width: 100%; height: 100%; box-sizing: border-box !important; padding: var(--ht-cell-vertical-padding, 4px) var(--ht-cell-horizontal-padding, 8px); background-color: var(--ht-cell-editor-background-color, #ffffff); box-shadow: inset 0 0 0 var(--ht-cell-editor-border-width, 2px) var(--ht-cell-editor-border-color, #1a42e8), 0 0 var(--ht-cell-editor-shadow-blur-radius, 0) 0 var(--ht-cell-editor-shadow-color, transparent); border: none; border-radius: 0; }
.feedback-editor button { background: var(--ht-background-color, #ffffff); color: var(--ht-foreground-color, #000000); border: 1px solid var(--ht-border-color, #e0e0e0); border-radius: var(--ht-border-radius, 4px); padding: 0; margin: 0; height: 100%; width: 33%; font-size: var(--ht-font-size, 14px); text-align: center; cursor: pointer; }
.feedback-editor button:hover { background: var(--ht-border-color, #e0e0e0); }
.feedback-editor button.active, .feedback-editor button.active:hover { background: var(--ht-accent-color, #1a42e8); color: #ffffff; border-color: var(--ht-accent-color, #1a42e8); }Overview
This guide shows how to create a feedback editor cell using emoji buttons (👍, 👎, 🤷) with React’s EditorComponent. The example uses a feature-roadmap table with a Feedback column; the editor supports quick selection, keyboard navigation (Arrow keys, Tab), and per-column configuration via external CSS.
Difficulty: Beginner Time: ~15 minutes Libraries: None
What You’ll Build
A cell that:
- Displays emoji feedback buttons (👍, 👎, 🤷) when editing
- Shows the selected emoji when viewing
- Supports keyboard navigation (Arrow Left/Right and Tab to cycle options)
- Provides click-to-select and closes the editor on choice
- Uses React’s
EditorComponentwith render prop and external CSS (e.g.feedback-editorclass) - Reads per-column options from
cellProperties.configinonPrepare
Prerequisites
npm install @handsontable/react-wrapperWhat you need:
- React 16.8+ (hooks support)
@handsontable/react-wrapperpackage- Basic React knowledge (hooks, JSX)
Import Dependencies
import { useState, useEffect, useCallback, ComponentProps } from 'react';import { HotTable, HotColumn, EditorComponent } from '@handsontable/react-wrapper';import { registerAllModules } from 'handsontable/registry';registerAllModules();What we’re importing:
EditorComponent- React component for creating custom editorsHotTableandHotColumn- React wrapper components- React hooks for state management
- Handsontable styles
Create the Editor Component
Create a React component that uses
EditorComponentwith the render prop pattern.type EditorComponentProps = ComponentProps<typeof EditorComponent<string>>;const FeedbackEditor = () => {const [config, setConfig] = useState<string[]>(['👍', '👎', '🤷']);return (<EditorComponent<string>>{({ value, setValue, finishEditing }) => (<div className="editor">{config.map((item) => (<buttonkey={item}className={`button ${value === item ? 'active' : ''}`}onClick={() => {setValue(item);finishEditing();}}>{item}</button>))}</div>)}</EditorComponent>);};What’s happening:
EditorComponentwraps your editor UI- The
childrenprop is a function that receives editor state value- Current editor valuesetValue- Function to update the valuefinishEditing- Function to save and close the editor- Render buttons for each option in the config
- Highlight the active button based on current value
Key concepts:
- Render prop pattern:
EditorComponentuses a function as children - State management:
valueandsetValueare provided byEditorComponent - React components: Use standard React patterns (JSX, className, onClick)
Add Styling
Style the editor container and buttons using CSS or inline styles.
const FeedbackEditor = () => {const [config, setConfig] = useState<string[]>(['👍', '👎', '🤷']);return (<EditorComponent<string>>{({ value, setValue, finishEditing }) => (<><style>{`.editor {box-sizing: border-box;display: flex;gap: 3px;padding: 3px;background: rgb(238, 238, 238);border: 1px solid rgb(204, 204, 204);border-radius: 4px;height: 100%;width: 100%;}.button.active {background: #007bff;color: white;}.button:hover {background: #f0f0f0;}.button {background: #fff;color: black;border: none;padding: 0;margin: 0;height: 100%;width: 100%;font-size: 16px;font-weight: bold;text-align: center;cursor: pointer;}`}</style><div className="editor">{config.map((item, _index, _array) => (<buttonkey={item}className={`button ${value === item ? 'active' : ''}`}onClick={() => {setValue(item);finishEditing();}}style={{width: `${100 / _array.length}%`}}>{item}</button>))}</div></>)}</EditorComponent>);};What’s happening:
- Container uses flexbox for horizontal button layout
- Buttons dynamically size based on config length
- Active button has blue background
- Hover effects for better UX
Key styling:
display: flex- Horizontal button layoutgap: 3px- Space between buttonswidth: ${100 / _array.length}%- Dynamic button width.activeclass - Highlights selected button
Read Config from Cell Properties
Use
onPrepareto read per-column configuration.const FeedbackEditor = () => {const [config, setConfig] = useState<string[]>(['👍', '👎', '🤷']);const onPrepare: EditorComponentProps['onPrepare'] = (_row,_column,_prop,_TD,_originalValue,cellProperties) => {// Read config from column definitionif (cellProperties.config) {setConfig(cellProperties.config as string[]);}};return (<EditorComponent<string> onPrepare={onPrepare}>{({ value, setValue, finishEditing }) => (// ... editor UI)}</EditorComponent>);};What’s happening:
onPrepareis called before the editor openscellPropertiescontains column-specific configuration- Read
configfromcellProperties.config - Update state to reflect column-specific options
Why this matters:
- Different columns can have different options
- One editor component, multiple configurations
- Dynamic options based on column settings
Add Keyboard Shortcuts
Add keyboard navigation using the
shortcutsprop.const FeedbackEditor = () => {const [config, setConfig] = useState<string[]>(['👍', '👎', '🤷']);const [shortcuts, setShortcuts] = useState<EditorComponentProps['shortcuts']>([]);const getNextValue = useCallback((value: string) => {const index = config.indexOf(value);return index === config.length - 1 ? config[0] : config[index + 1];}, [config]);const getPrevValue = useCallback((value: string) => {const index = config.indexOf(value);return index === 0 ? config[config.length - 1] : config[index - 1];}, [config]);useEffect(() => {setShortcuts([{keys: [['ArrowRight'], ['Tab']],callback: ({ value, setValue }, _event) => {setValue(getNextValue(value));return false; // Prevent default Tab behavior}},{keys: [['ArrowLeft']],callback: ({ value, setValue }, _event) => {setValue(getPrevValue(value));}}]);}, [config, getNextValue, getPrevValue]);return (<EditorComponent<string> shortcuts={shortcuts}>{({ value, setValue, finishEditing }) => (// ... editor UI)}</EditorComponent>);};What’s happening:
- ArrowRight/Tab: Move to next option (wraps to first if at end)
- ArrowLeft: Move to previous option (wraps to last if at start)
callbackreceives{ value, setValue, finishEditing }as first parameter- Return
falseto prevent default behavior (e.g., Tab moving to next cell)
Keyboard navigation benefits:
- Fast selection without mouse
- Accessible for keyboard-only users
- Intuitive left/right navigation
- Tab cycles through options instead of moving cells
Complete Editor Component
Put it all together:
type EditorComponentProps = ComponentProps<typeof EditorComponent<string>>;const FeedbackEditor = () => {const [config, setConfig] = useState<string[]>(['👍', '👎', '🤷']);const [shortcuts, setShortcuts] = useState<EditorComponentProps['shortcuts']>([]);const onPrepare: EditorComponentProps['onPrepare'] = (_row,_column,_prop,_TD,_originalValue,cellProperties) => {if (cellProperties.config) {setConfig(cellProperties.config as string[]);}};const getNextValue = useCallback((value: string) => {const index = config.indexOf(value);return index === config.length - 1 ? config[0] : config[index + 1];}, [config]);const getPrevValue = useCallback((value: string) => {const index = config.indexOf(value);return index === 0 ? config[config.length - 1] : config[index - 1];}, [config]);useEffect(() => {setShortcuts([{keys: [['ArrowRight'], ['Tab']],callback: ({ value, setValue }, _event) => {setValue(getNextValue(value));return false;}},{keys: [['ArrowLeft']],callback: ({ value, setValue }, _event) => {setValue(getPrevValue(value));}}]);}, [config, getNextValue, getPrevValue]);return (<EditorComponent<string> onPrepare={onPrepare} shortcuts={shortcuts}>{({ value, setValue, finishEditing }) => (<><style>{`.editor {box-sizing: border-box;display: flex;gap: 3px;padding: 3px;background: rgb(238, 238, 238);border: 1px solid rgb(204, 204, 204);border-radius: 4px;height: 100%;width: 100%;}.button.active:hover,.button.active {background: #007bff;color: white;}.button:hover {background: #f0f0f0;}.button {background: #fff;color: black;border: none;padding: 0;margin: 0;height: 100%;width: 100%;font-size: 16px;font-weight: bold;text-align: center;cursor: pointer;}`}</style><div className="editor">{config.map((item, _index, _array) => (<buttonkey={item}className={`button ${value === item ? 'active' : ''}`}onClick={() => {setValue(item);finishEditing();}}style={{width: `${100 / _array.length}%`}}>{item}</button>))}</div></>)}</EditorComponent>);};What’s happening:
- State management:
configandshortcutsmanaged with React hooks - onPrepare: Reads column-specific config
- shortcuts: Keyboard navigation handlers
- Render prop: Renders buttons based on config
- Styling: CSS-in-JS for editor appearance
- State management:
Use in Handsontable
Use the editor component in your
HotTable:const ExampleComponent = () => {return (<HotTableautoRowSize={true}rowHeaders={true}autoWrapRow={true}licenseKey="non-commercial-and-evaluation"height="auto"data={data}colHeaders={true}><HotColumnwidth={250}editor={FeedbackEditor}config={['👍', '👎', '🤷']}data="feedback"title="Feedback"/><HotColumnwidth={250}editor={FeedbackEditor}config={['1', '2', '3', '4', '5']}data="stars"title="Rating (1-5)"/></HotTable>);};What’s happening:
editor={FeedbackEditor}- Assigns the editor component to the columnconfig={['👍', '👎', '🤷']}- Column-specific options- Same editor component, different configurations per column
Key features:
- Reusable editor component
- Per-column configuration
- Type-safe with TypeScript
How It Works - Complete Flow
- Initial Render: Cell displays the emoji value (👍, 👎, or 🤷)
- User Double-Clicks or Enter: Editor opens,
onPreparereads column config - Editor Opens:
EditorComponentpositions container over cell - Button Display: All options visible, current value highlighted
- User Interaction:
- Click a button →
setValue(item)andfinishEditing()called - Press ArrowLeft/Right → Shortcut callback updates value
- Press Tab → Cycles through options (prevents default cell navigation)
- Click a button →
- Visual Feedback: Selected button highlighted in blue
- User Confirms: Press Enter, click button, or click away
- Save: Value saved to cell
- Editor Closes: Cell shows selected emoji
Enhancements
Custom Renderer with Styling
Add a custom renderer to style the emoji display:
import { rendererFactory } from 'handsontable/renderers';const cellDefinition = {renderer: rendererFactory(({ td, value }) => {td.innerHTML = `<div style="text-align: center; font-size: 1.5em; padding: 4px;">${value || '🤷'}</div>`;})};// Use in HotColumn<HotColumneditor={FeedbackEditor}renderer={cellDefinition.renderer}config={['👍', '👎', '🤷']}data="feedback"/>What’s happening:
- Center-aligns the emoji
- Increases font size for better visibility
- Adds padding for spacing
More Feedback Options
Add more emoji options:
<HotColumneditor={FeedbackEditor}config={['👍', '👎', '🤷', '❤️', '🔥', '⭐']}data="feedback"/>The editor automatically adjusts button widths based on config length.
Custom Button Styling
Enhanced button appearance with CSS:
<style>{`.button {padding: 8px;border: 2px solid #ddd;background: white;color: #333;border-radius: 4px;cursor: pointer;font-size: 1.2em;transition: all 0.2s;}.button.active {border-color: #007bff;background: #007bff;color: white;}.button:hover {transform: scale(1.05);box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);}`}</style>Dynamic Config from Cell Properties
The
onPreparehook already handles this! Just pass different configs:<HotColumneditor={FeedbackEditor}config={['👍', '👎', '❤️', '🔥']}data="feedback"/>Tooltip on Hover
Add tooltips to buttons:
{config.map((item) => {const tooltips: Record<string, string> = {'👍': 'Positive feedback','👎': 'Negative feedback','🤷': 'Neutral feedback'};return (<buttonkey={item}className={`button ${value === item ? 'active' : ''}`}onClick={() => {setValue(item);finishEditing();}}title={tooltips[item] || ''}>{item}</button>);})}Text Labels Instead of Emojis
Use text buttons for clarity:
<HotColumneditor={FeedbackEditor}config={['Positive', 'Negative', 'Neutral']}data="feedback"/>The editor works with any string values, not just emojis.
Using External CSS File
Move styles to a separate CSS file:
feedback-editor.css .editor {box-sizing: border-box;display: flex;gap: 3px;padding: 3px;background: rgb(238, 238, 238);border: 1px solid rgb(204, 204, 204);border-radius: 4px;height: 100%;width: 100%;}.button.active {background: #007bff;color: white;}.button:hover {background: #f0f0f0;}.button {background: #fff;color: black;border: none;padding: 0;margin: 0;height: 100%;width: 100%;font-size: 16px;font-weight: bold;text-align: center;cursor: pointer;}import './feedback-editor.css';const FeedbackEditor = () => {// ... component code without <style> tag};
Accessibility
React buttons are inherently accessible, but you can enhance them:
{config.map((item, index) => ( <button key={item} className={`button ${value === item ? 'active' : ''}`} onClick={() => { setValue(item); finishEditing(); }} aria-label={`${item} feedback option`} aria-pressed={value === item} tabIndex={value === item ? 0 : -1} > {item} </button>))}Keyboard navigation:
- Tab: Navigate to editor (focuses active button)
- Arrow Left/Right: Cycle through options (via shortcuts)
- Enter: Select current option and finish editing
- Escape: Cancel editing
- Click: Direct selection
ARIA attributes:
aria-label: Describes each buttonaria-pressed: Indicates selected statetabIndex: Controls keyboard focus order
Performance Considerations
Why This Is Fast
- React Virtual DOM: Efficient updates only when value changes
- No External Libraries: Zero overhead beyond React
- Efficient Re-renders: Only re-renders when config or value changes
- Native Events: Browser-optimized click handlers
React Hooks Optimization
The useCallback and useEffect hooks ensure shortcuts are only recreated when config changes:
const getNextValue = useCallback((value: string) => { const index = config.indexOf(value); return index === config.length - 1 ? config[0] : config[index + 1];}, [config]); // Only recreate if config changes
useEffect(() => { setShortcuts([...]);}, [config, getNextValue, getPrevValue]); // Only update when dependencies changeTypeScript Support
EditorComponent is fully typed. You can specify the value type:
<EditorComponent<string>> {({ value, setValue, finishEditing }) => { // TypeScript knows value is string | undefined // TypeScript knows setValue accepts string return ( // ... editor UI ); }}</EditorComponent>For number-based feedback:
<EditorComponent<number>> {({ value, setValue, finishEditing }) => { // TypeScript knows value is number | undefined return ( // ... editor UI ); }}</EditorComponent>Best Practices
- Use
onPreparefor per-cell configuration - AccesscellPropertiesto read custom options - Handle keyboard events properly - Use shortcuts for navigation
- Call
finishEditing()appropriately - When user confirms changes (Enter, blur, button click) - Keep render prop function simple - Extract complex logic into separate components or hooks
- Use
useCallbackfor helper functions - Prevents unnecessary re-renders - Update shortcuts in
useEffect- Ensures shortcuts match current config
Congratulations! You’ve created a simple feedback editor with emoji buttons using React’s EditorComponent, perfect for quick feedback selection in your data grid!