Stepper: React's useImperativeHandle

14 Dec 2024 - Ben

State encapsulation is pretty good in React: one way data binding via props and callbacks for notifying of changing events. But how do I change the state of a component from outside it’s declaration?

For example, I have a Stepper component that shows one of its children at a time, where the Stepper component manages the state.
<Stepper>
  <div>Step 1</div>
  <div>Step 2</div>
</Stepper>
function Stepper({children}) {
    const [step, setStep] = useState(0);
    return <div>{React.Children.toArray(children)[step]}</div>;
}

But with the experience I have now, I’m suggesting you expose state changes on the ref of the Stepper component, giving the devs using your stepper component an imperative way to effect the Stepper’s internal state. No component needs to be aware of where they are in the DOM or the React VDOM, and the component calling the Stepper component can coodinate stepping through without needing to know the details. Below I have examples of nearly all of these options. Maybe you’ll look at some and see patterns that you’ve introduced. Or patterns you’ve struggled to work with. Maybe you won’t agree with my final result, or my reasoning, but I hope I can at least give you some familiarity with a feature in React I don’t see very often: useImperativeHandle.

Options

Forced UI

The Stepper could have UI to move between steps
function Stepper({children}) {
    const [step, setStep] = useState(0);
    const nextStep = useCallback(() => setStep(step => step + 1), [setStep]);
    const prevStep = useCallback(() => setStep(step => step - 1), [setStep]);
    return (<div>
        {React.Children.toArray(children)[step]}
        <button onClick={prevStep}>&lt;</button>
        <button onClick={nextStep}>&gt;</button>
    </div>);
}
but maybe I don't want to define UI for that. I could mandate UI, but let the user pass in the components:
function Stepper({children, nextButton: NextButton, prevButton: PrevButton}) {
    const [step, setStep] = useState(0);
    const nextStep = useCallback(() => setStep(step => step + 1), [setStep]);
    const prevStep = useCallback(() => setStep(step => step - 1), [setStep]);
    return (<div>
        {React.Children.toArray(children)[step]}
        <PrevButton onClick={prevStep}>&lt;</PrevButton>
        <NextButton onClick={nextStep}>&gt;</NextButton>
    </div>);
}

Which will help a bit, but those components aren’t very flexible. Placement can’t be changed and they can’t really be part of the content. If I wanted to have them be before/after I could add a prop, but I could add a lot of props to make it more flexible.. or try a different approach entirely.

Hooks and Props

If the step were a prop, and I just listen for the change, then there's no UI to worry about.
function Stepper({children, step}) {
    return <div>{React.Children.toArray(children)[step]}</div>;
}
// usage
function SteppedComponent () {
    const [step, setStep] = useStep(0);
    const nextStep = useCallback(() => setStep(step => step + 1), [setStep]);
    const prevStep = useCallback(() => setStep(step => step - 1), [setStep]);
    return (
        <Stepper step={step}>
            <div>
                Slide 1
                <button onClick={nextStep}>
                    Next Step
                </button>
            </div>
            <div>
                Slide 2
                <button onClick={() => {stepperRef.current?.prevStep()}}>
                    Previous Step
                </button>
            </div>
        </Stepper>
    );
}

but now this Stepper component doesn’t really do anything. You still have to maintain the correct state outside of it, remember all the rules for changing steps, and reimplement this every time you use a Stepper.

I could have a hook, or pair the Stepper with a hook. Now the person using Stepper doesn't need to know all the rules.
function Stepper({children, step}) {
    return <div>{React.Children.toArray(children)[step]}</div>;
}
function useSteps(defaultStep = 0) {
    const [step, setStep] = useStep(0);
    const nextStep = useCallback(() => setStep(step => step + 1), [setStep]);
    const prevStep = useCallback(() => setStep(step => step - 1), [setStep]);
    return [step, {nextStep, prevStep}];
}
// usage
function SteppedComponent () {
    const [step, {nextStep, prevStep}] = useSteps;
    return (
        <Stepper step={step}>
            <div>
                Slide 1
                <button onClick={nextStep}>
                    Next Step
                </button>
            </div>
            <div>
                Slide 2
                <button onClick={() => {stepperRef.current?.prevStep()}}>
                    Previous Step
                </button>
            </div>
        </Stepper>
    );
}

but they still need to remember the hook! There’s a lot of manual coordination needed with this approach. I could use only a hook, but there is some UI part involved in my Stepper Component in this example. It only shows one child at a time. I think it makes sense to render it as part of the React Tree. What if, instead, I passed information down the tree?

Context

My Stepper component could be a Provider and coordinate that way.
const StepperContext = createContext({step: 0, nextStep() {}, prevStep() {}});
const useStepperContext = useContext(StepperContext);
function Stepper({children, step}) {
    const [step, setStep] = useStep(0);
    const nextStep = useCallback(() => setStep(step => step + 1), [setStep]);
    const prevStep = useCallback(() => setStep(step => step - 1), [setStep]);
    return (
        <StepperContext.Provider value=>
            <div>
                {React.Children.toArray(children)[step]}
            </div>
        </StepperContext.Provider>);
}
// usage
/** context aware child */
function StepperChildOne() {
    const {nextStep} = useStepperContext(); 
    return(<div>
        Slide 1
        <button onClick={nextStep}>
            Next Step
        </button>
    </div>);
}
function SteppedComponent () {
    const [step, {nextStep, prevStep}] = useSteps;
    return (
        <Stepper step={step}>
            <StepperChildOne />
            /* function as child */
            <StepperContext.Consumer>{({prevStep}) => (
                <div>
                    Slide 2
                    <button onClick={() => {stepperRef.current?.prevStep()}}>
                        Previous Step
                    </button>
                </div>)}
            </StepperContext.Consumer>
        </Stepper>
    );
}

This limits updates to children, but lessens the coordination between components. The children need to know to access my Context, making them less independent. They’re only useful in this Context. I could have an army of Stepper-aware components, or ones to help coordinate, but no approach is going to capture every possible look for a UI. And what if I want them changing automatically on an interval?

Technically, Context.Consumer would allow updates this component, but React and I agree we don’t like this approach. They call it the old way, I think it clutters the component.

So I want:

If I’m writing components like the DOM, then all children should have events/callback I can access for the various things they can do. Can I use those? Can I plug in to what the children of my Stepper should already have available? But what does the component do to change the step of the Stepper on those events?

Events

I don’t want to use event bubbling, because what event would I listen for? Clicks or form submissions are the obvious answer, but I usually call stopProgation on forms and clicks could be any number of interactions where I don’t want to change the step.

Something meant for the Stepper itself would be better. But with custom events, my children still need to know if they’re in the context of a Stepper. And the dev writing using the Stepper component would need to know what event to fire!

This is not as big an issue in plain HTML. With templates you have a better understanding of the flow/make up of your page. For actions on the component, you can set methods on the component directly to call. In React, the closest thing to that is the ref and it’s imperative handle.

Refs

Imperative changes allow us to explicitly (not implicitly or declaratively, like props) tell our object to do something. Think of focusing an element, it imperatively tells the browser to set focus on that element.

With our Stepper component, we want the control to tell it when to move to the next or previous step. All the builtin components have refs where we can do these types of action, but how do we set up a component for imperative changes?

Expose a ref via `forwardRef` and attach actions to it via `useImperativeHandle`:
// makes typescript happier and Intellisense easier to follow
type StepperRef = {
    nextStep(): void;
    prevStep(): void;
};
const Stepper = forwardRef(({children}, ref: StepperRef) => {
    const [step, setStep] = useStep(0);
    useImperativeHandle(ref, () => ({
        nextStep() { setStep(step => step + 1); },
        prevStep() { setStep(step => step - 1); }
    }), [setStep]); // setStep doesn't change, so this a very stable reference
    return <div>{React.Children.toArray(children)}</div>;
});
// Usage:
function SteppedComponent() {
    const stepperRef = useRef<StepperRef | null>();
    return (
        <Stepper ref={stepperRef}>
            <div>
                Step 1
                <button onClick={() => {stepperRef.current?.nextStep()}}>
                    Next Step
                </button>
            </div>
            <div>
                Step 2
                <button onClick={() => {stepperRef.current?.prevStep()}}>
                    Previous Step
                </button>
            </div>
        </Stepper>
    );
}

In Conclusion

Now I can have buttons, form submissions, timeouts, intervals, you name it, this Stepper can handle it. Just tell it when to move to the previous or next step. Bring Your Own Event, so to speak, because Stepper doesn’t prescribe it. SteppedComponent doesn’t coordinate any state. There’s very little boilerplate.

Stepper can tightly control the behavior of state changes. In my examples, I don’t have bounds on next/previous steps, so you can “go too far”. But we could easily make it wrap, or just stay on the last/first step. Or call a callback passed to Stepper when we’re at the end. Stepper could let SteppedComponent set the step explicitly by exposing a setStep function on our imperative handle. Stepper props can be used to define the types of behavior the steps should have, instead of necessitating SteppedComponent making that logic.

It opens up a world of possibilities, and the only code we’ve added from our beginning example is a useRef outside the component and a useImperativeHandle inside the component. This is behavior that will need to be documented, but in a similar way to how props are documented. It isn’t as opaque as Context or CustomEvents.

React’s docs say you should limit your use of imperative code and prefer declarative instead. At the bottom of their useImperativeHandle page, they even point towards using Effects instead. But some actions need to be done imperatively. Again from React’s site: “focusing a node, triggering an animation, selecting text and so on”. I think this type of action and state encapsulation benefits from the use of some imperative code, exposed on a ref via useImperativeHandle.


Taking it Further

More Ref examples, with the same Stepper Component. In these examples I assume we’ve expanded the functionality beyond my example Stepper above.

Next step on interval
// move to the next slide every 1 second. Assumes wrapping behavior of Stepper
function SteppedComponent() {
    const interval = useRef<number | null>();
    const stepperRef = useCallback((ref) => {
        if(!!ref) interval.current = setInterval(() => ref.nextStep(), 1000);
        else {
            clearInterval(interval.current);
            interval.current = 0;
        }
    });
    return (
        <Stepper ref={stepperRef}>
            <div>Step 1</div>
            <div>Step 2</div>
        </Stepper>
}
onComplete handler

Perform an action after reaching the last step of the Stepper. Assumes an onComplete handler. In this example, TextboxPreview’s onConfirm could also call onSubmit directly, but I like the fluidity of this approach. I like being able to look at what happens when the Stepper completes separately from how it gets there. And since the last step is necessarily the last child, I already know where to look (this is an implementation detail I suppose, but with arrays being so commonplace, intuitive to most users)

function ConfirmedTextSubmission({onSubmit}) {
    const stepperRef = useRef<StepperRef | null>(null);
    const [preview, setPreview] = useState('');
    const [hidden, setHidden] = useState(false);
    return (
        <div hidden={hidden}
            <Stepper onComplete={() => { onSubmit(preview); }}>
                <TextBoxForm 
                    onSubmit={(text) => {
                        setPreview(text);
                        stepperRef.current?.nextStep();
                        }}
                />
                <TextBoxPreview onConfirm={() => { stepperRef.current?.nextStep()}}>
                    {preview}
                </TextBoxPreview>
            </Stepper>
        </div>
    );
}