We want to build and test a counter component built with React and TypeScript. So where do we start? First of all looking at the component I can see it is made up of 2 button components that increase and decrease the count and an input component that shows the value of the count.
Building a Counter Component
Importing our Components
As we already have a button component and input component already created I will just import them as well as import React and useState and the styles.
import React, { useState } from 'react'
import { Button } from '@learn-bit-react/base-ui.ui.button'
import { Input } from '@learn-bit-react/base-ui.ui.forms.input'
import styles from './counter.module.scss'
Creating our Types
The next thing we need to define is our types for our props. We want the consumer to be able to start the count at any number as well as end it at any number. It might be useful to have a count starting at zero for scoring or 1 for purchasing a product. We also want the consumer to be able to increment and decrement by a given amount such as 1 or 5. And finally we want to have a function that is called when the count is changed.
export type CounterProps = {
/**
* min Value of counter
*/
min?: number,
/**
* max Value
*/
max?: number,
/**
* increment value
*/
increment?: number,
/**
* decrement value
*/
decrement?: number,
/**
* a function that registers the count when changed
*/
onCountChange: (count: number) => void
} & React.HTMLAttributes<HTMLDivElement>
Creating our Component
Next we need to create our component which we call Counter and pass in the props with some default values for the min
, max
, increment
and decrement
. We then add in the components we need. The Button component has a prop of counter
which will give us the styling we need for a counter button. It also accepts a prop of onClick
which will be called when the button is clicked. We can give this the value of handleClickSubtract
and later we can write the function for it to decrease the count.
Next is our input component which will be of type Number and here we pass in the the min
and max
props as well as the value equal to count
and an onChange
function that will be called when the value is changed. We will need to create the function for handleClick
later.
And finally we add our next button component which will be responsible for decreasing the count. This accepts the prop of counter for styling and an onClick
with the value of handleClickAdd
which we will create later.
...
export function Counter({
...
}: CounterProps) {
return (
<div className={styles.counter}>
<Button counter onClick={handleClickSubtract}>
-
</Button>
<Input
className={styles.counterInput}
type="number"
min={min}
max={max}
value={count}
width="80px"
onChange={handleClick}
/>
<Button counter onClick={handleClickAdd}>
+
</Button>
</div>
)
}
Now that we have our component setup visually we need to add functionality to it. We will use the useState
hook to create a state variable called count
and set the default to be value of our min
prop.
Using State
const [count, setCount] = useState(min)
We will also create a function called handleClickAdd
function that will check to see if the count value is less the the max
value and if it is then we will increase the count by the increment
value. It will also call the onCountChange
function and increment the count. This function is what will let us pass the count value to another component if needed.
Add Function
function handleClickAdd() {
if (count < max) {
setCount(count + increment)
onCountChange(count + increment)
}
}
Subtract Function
We then need to do the same for the handleClickSubtract
function and if the count is greater than the min
value then we will decrease the count by the decrement
value as well as call the onCountChange
function.
function handleClickSubtract() {
if (count > min) {
setCount(count - decrement)
onCountChange(count - decrement)
}
}
Handle Click Function
Next is our handleClick
function which will be called when the input value is manually changed. We will check to see if the value is less than the max
value and if it is then we will set the count
to the value of the input as well as update the onCountChange
function.
function handleClick(e) {
setCount(e.target.valueAsNumber)
onCountChange(e.target.valueAsNumber)
}
Full Code
And now our counter component is ready to go.
import React, { useState } from 'react'
import { Button } from '@learn-bit-react/base-ui.ui.button'
import { Input } from '@learn-bit-react/base-ui.ui.forms.input'
import styles from './counter.module.scss'
export type CounterProps = {
/**
* min Value of counter
*/
min?: number,
/**
* max Value
*/
max?: number,
/**
* increment value
*/
increment?: number,
/**
* decrement value
*/
decrement?: number,
/**
* a function that registers the count when changed
*/
onCountChange: (count: number) => void
} & React.HTMLAttributes<HTMLDivElement>
export function Counter({
min = 1,
max = 20,
increment = 1,
decrement = 1,
onCountChange
}: CounterProps) {
const [count, setCount] = useState(min)
function handleClickAdd() {
if (count < max) {
setCount(count + increment)
onCountChange(count + increment)
}
}
function handleClickSubtract() {
if (count > min) {
setCount(count - decrement)
onCountChange(count - decrement)
}
}
function handleClick(e) {
setCount(e.target.valueAsNumber)
onCountChange(e.target.valueAsNumber)
}
return (
<div className={styles.counter}>
<Button counter onClick={handleClickSubtract}>
-
</Button>
<Input
className={styles.counterInput}
type="number"
min={min}
max={max}
value={count}
width="80px"
onChange={handleClick}
/>
<Button counter onClick={handleClickAdd}>
+
</Button>
</div>
)
}
Creating our Compositions
When building our components we need to see what is going on and for this in Bit we use compositions so we can build our component as an independent component and see it in isolation using bits dev server. However if you are not using bit you can just import the component into another component or into your app and see it working there.
Our composition uses our previously created Counter component and also shows what the count value is in a <p>
tag which of course could be another component. This shows us that our count value is being updated in the UI in real time. We are not passing in any min
or max
values here as we want to use the default values but we could create another composition that shows a different counter with different values.
import React, { useState } from 'react'
import { Counter } from './counter'
export const BasicCounter = () => <Counter onCountChange={count => count} />
export function CounterWithSetCount() {
const [count, setCount] = useState(1)
return (
<>
<Counter
onCountChange={count => {
setCount(count)
}}
/>
<p>Count is: {count}</p>
</>
)
}
Testing Our Counter
The final step is to write some tests for our counter to make sure it works as it should. Of course some would say that you should create the tests first but that's up in which way you want to build things.
In our test file we need to import React, as well as the render
and screen
functions from @testing-library/react
and our BasicCounter
component. As we want to test the buttons we need to add the userEvent
method from @testing-library/user-event
.
import React from 'react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { BasicCounter } from './counter.composition'
We can then think about what we want to test and write todos for each of them.
it.todo('should render a counter with value of 1')
it.todo('should increase count when plus button is clicked')
it.todo('should decrease count when minus button is clicked')
it.todo('should not decrease to less than 1')
We then render our BasicCounter
component and use the screen
method with the function of getByRole
. As we don't know what role is available to us we can use screen.getByRole('blah')
which shows us that blah doesn't exist but that spinbutton
does which is the role for our input of type number. We then expect our counter to have the value of 1 which was the min default value.
it('should render a counter with value of 1', () => {
render(<BasicCounter />)
basicCounter = screen.getByRole('spinbutton')
expect(basicCounter).toHaveValue(1)
})
Our next test also needs to render the BasicCounter
component as well as the the Button component with the name +
and we know that we will also have to render the Button component with the name -
in the last tests so we can go ahead and create a beforeEach
function which will render these before each test is called.
let basicCounter
let increaseCount
let decreaseCount
beforeEach(() => {
render(<BasicCounter />)
basicCounter = screen.getByRole('spinbutton')
increaseCount = screen.getByRole('button', { name: '+' })
decreaseCount = screen.getByRole('button', { name: '-' })
})
Our first test will now look like this:
it('should render a counter with value of 1', () => {
expect(basicCounter).toHaveValue(1)
})
Our second test will make sure sure the value starts at 1 and when the button is clicked it will update to 2 using the userEvent
method.
it('should increase count when plus button is clicked', () => {
expect(basicCounter).toHaveValue(1)
userEvent.click(increaseCount)
expect(basicCounter).toHaveValue(2)
})
Our next test will start by calling the userEvent
method to click the +
button and then make sure that the value is 2. Now we can test the decrease button by clicking that and making sure the value is back to 1.
it('should decrease count when minus button is clicked', () => {
userEvent.click(increaseCount)
expect(basicCounter).toHaveValue(2)
userEvent.click(decreaseCount)
expect(basicCounter).toHaveValue(1)
})
Our last test will make sure that the value is not less than 1 and when the minus button is clicked it will still have the value of 1 as that is the min value allowed.
it('should not decrease to less than 1', () => {
expect(basicCounter).toHaveValue(1)
userEvent.click(decreaseCount)
expect(basicCounter).toHaveValue(1)
})
All our tests are passing and if we break anything in our component our tests will break as well.
Full Code Example
import React from 'react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { BasicCounter } from './counter.composition'
let basicCounter
let increaseCount
let decreaseCount
beforeEach(() => {
render(<BasicCounter />)
basicCounter = screen.getByRole('spinbutton')
increaseCount = screen.getByRole('button', { name: '+' })
decreaseCount = screen.getByRole('button', { name: '-' })
})
it('should render a counter with value of 1', () => {
expect(basicCounter).toHaveValue(1)
})
it('should increase count when plus button is clicked', () => {
expect(basicCounter).toHaveValue(1)
userEvent.click(increaseCount)
expect(basicCounter).toHaveValue(2)
})
it('should decrease count when minus button is clicked', () => {
userEvent.click(increaseCount)
expect(basicCounter).toHaveValue(2)
userEvent.click(decreaseCount)
expect(basicCounter).toHaveValue(1)
})
it('should not decrease to less than 1', () => {
expect(basicCounter).toHaveValue(1)
userEvent.click(decreaseCount)
expect(basicCounter).toHaveValue(1)
})
Conclusion
And that's it. We now have a counter component that works as we would expect and can now be used in the component where it should be used knowing that it will work correctly. Compositions are a great way of seeing the different states of our components and we can then use the composition file to understand what we need to do to make our component work when using it in our next component/app.
We should also document our component so that it contains clear instructions and examples which makes it even easier for our consumer to understand what the component does and how to use it. And of course tests make sure our component not only works as expected but also that if we do make any changes to it our tests ensure that it can not be exported if our tests are broken meaning if we do have any breaking changes we can fix our tests and release a new major version of our component.