Feedback
This tutorial shows you how to build an emoji feedback cell in React using Handsontable’s EditorComponent, with per-column configuration and keyboard navigation.
import { useState, useEffect, useCallback } from 'react';import { HotTable, HotColumn, EditorComponent } from '@handsontable/react-wrapper';import { registerAllModules } from 'handsontable/registry';import './example1.css';
// 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';import './example1.css';
// 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 — 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 focused - 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
What you learned
You built an emoji feedback cell editor in React using Handsontable’s EditorComponent. You used the render prop pattern to render configurable option buttons, onPrepare to read per-column configuration from cellProperties, and the shortcuts prop for keyboard navigation.
Next steps
- Feedback (JavaScript) - The same pattern using
editorFactorywith Handsontable CSS tokens. - Feedback Editor (Angular) - The Angular version using
HotCellEditorAdvancedComponent. - Star Rating (React) - Another React editor using
EditorComponentfor numeric selection.