Model Class State With TypeScript Interfaces

In Class Components With Props we made a child component using a class, with one property passed in. We use classes for child components when they have state or need hooking into one of React’s lifecycle methods.

That’s the topic of this step. We’re going to have a reusable counter component that has a count of clicks.

This step, though, will be just the minimum: no actual clicking to update state, for example. We will stick to introducing component state and modeling it in TypeScript.

Always Start With a Test

It’s becoming our pattern: we write a failing test first, then implement, then wire into the parent. To begin, have Counter.tsx in the left-hand tab and Counter.test.tsx in the right-hand tab. Also, stop the start process if it is running and make sure the Jest run config is running.

Here’s a Counter.test.tsx test to show that the counter starts at zero, which fails, because we have a static <span>1</span>:

it('should default start at zero', () => {
    const wrapper = shallow(<Counter label={'Current'}/>);
    expect(wrapper.find('.counter span').text())
        .toBe('1');
});

Over in Counter.tsx, let’s write our interface first. What does the local state look like? Pretty easy:

interface ICounterState {
    count: number
}

Now the class definition and constructor can setup state, which we’ll use in the render method:

class Counter extends React.Component<ICounterProps, ICounterState> {
    public static defaultProps = {
        label: 'Count'
    };

    constructor(props: ICounterProps) {
        super(props);
        this.state = {
            count: 0
        };
    }

    public render() {
        return (
            <div className="counter">
                <label>{this.props.label}</label>
                <span>{this.state.count}</span>
            </div>
        );
    }
}

Several things changed in this:

  • React.Component<> has a generic with a second value, for the state
  • We added a class constructor, which per the TSLint style, comes after static methods
  • This constructor is passed the props (which we’ll use in a moment)
  • The constructor must call the superclass’s constructor
  • We assign some local state
  • In the JSX/TSX, we got autocompletion not only on .state, but also .count

Starting Value

Sometimes we want a counter that starts somewhere besides zero. Let’s pass in an optional prop for the starting value. First, the test in Counter.test.tsx:

it('should custom start at another value', () => {
    const wrapper = shallow(<Counter label={'Current'} start={10}/>);
    expect(wrapper.find('.counter span').text())
        .toBe('10');
});

As before, our test fails, but before that, our IDE warns us that we have violated the <Counter/> contract. We’ll fix the interface in Counter.tsx:

interface ICounterProps {
    label?: string
    start?: number
}

Then, add it to the defaultProps:

public static defaultProps = {
    label: 'Count',
    start: 0
};

Finally, change the component state to get its initial value from the component props:

constructor(props: ICounterProps) {
    super(props);
    this.state = {
        count: props.start
    };
}

When we do this, though, TypeScript gets mad. We said the start property was optional, by putting a ? in the interface field. As the compiler error explains, this means it can be a number or a null. In the component state, though, we say it can only be a number.

TypeScript 2.7 provides an elegant fix for this with definite assignment assertion. Sometimes you know better than the compiler. At the point of assignment, make an “I’m sure” assignment – a definite assignment – by suffixing the value with an exclamation:

constructor(props: ICounterProps) {
    super(props);
    this.state = {
        count: props.start!
    };
}

Not only is the compiler happy, but our test is happy. We have a <Counter/> component which shows a value from local component state and which can optionally be passed in a starting value.

Note

We could also have solved the definite assignment issue using a ternary. TypeScript knows how to infer the type from such “control flow.”

Wire Into UI

We wrap up each step by wiring the standalone component changes into the parent component, first through testing, then by looking in the browser. First up, we open App.test.tsx and add a single line to test the initial counter value:

it('renders the app and the heading', () => {
    const wrapper = mount(<App/>);
    expect(wrapper.find('h1').text())
        .toBe('Hello React');
    expect(wrapper.find('.counter label').text())
        .toBe('Current');
    expect(wrapper.find('.counter span').text())
        .toBe('0');
});

What changes in App.tsx? In this case, nothing. We want to use the default value of zero.

If you’d like, restart the start run configuration and view this in the browser, so make sure everything still looks good. When done, terminate the start script.