Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cozy-lizards-scream.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'react-simplikit': minor
---

Pass the previous state value as the second argument to useInputState's transformValue callback.
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ The returned `onChange` handler works with both `<input>` and `<textarea>` eleme
```ts
function useInputState(
initialValue: string | (() => string) = '',
transformValue: (value: string) => string = (v: string) => v
transformValue: (nextValue: string, previousValue: string) => string = (
nextValue: string
) => nextValue
): [
value: string,
onChange: ChangeEventHandler<HTMLInputElement | HTMLTextAreaElement>,
Expand All @@ -25,8 +27,8 @@ function useInputState(

<Interface
name="transformValue"
type="(value: string) => string"
description="A function to transform the input value. Defaults to an identity function that returns the input unchanged."
type="(nextValue: string, previousValue: string) => string"
description="A function to transform the input value. The previous state value is available as its second argument. Defaults to an identity function that returns the next input unchanged."
/>

### Return Value
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ El controlador `onChange` que devuelve funciona tanto con elementos `<input>` co
```ts
function useInputState(
initialValue: string | (() => string) = '',
transformValue: (value: string) => string = (v: string) => v
transformValue: (nextValue: string, previousValue: string) => string = (
nextValue: string
) => nextValue
): [
value: string,
onChange: ChangeEventHandler<HTMLInputElement | HTMLTextAreaElement>,
Expand All @@ -25,8 +27,8 @@ function useInputState(

<Interface
name="transformValue"
type="(value: string) => string"
description="Una función para transformar el valor del campo de entrada. La función predeterminada es una función identidad que devuelve el valor sin cambios."
type="(nextValue: string, previousValue: string) => string"
description="Una función para transformar el valor del campo de entrada. Recibe el valor anterior del estado como segundo argumento. La función predeterminada es una función identidad que devuelve el siguiente valor de entrada sin cambios."
/>

### Valor de retorno
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
```ts
function useInputState(
initialValue: string | (() => string) = '',
transformValue: (value: string) => string = (v: string) => v
transformValue: (nextValue: string, previousValue: string) => string = (
nextValue: string
) => nextValue
): [
value: string,
onChange: ChangeEventHandler<HTMLInputElement | HTMLTextAreaElement>,
Expand All @@ -25,8 +27,8 @@ function useInputState(

<Interface
name="transformValue"
type="(value: string) => string"
description="入力値を変換する関数です。デフォルトは入力値をそのまま返す恒等関数です。"
type="(nextValue: string, previousValue: string) => string"
description="入力値を変換する関数です。前の状態値を第2引数として受け取ります。デフォルトは次の入力値をそのまま返す恒等関数です。"
/>

### 戻り値
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
```ts
function useInputState(
initialValue: string | (() => string) = '',
transformValue: (value: string) => string = (v: string) => v
transformValue: (nextValue: string, previousValue: string) => string = (
nextValue: string
) => nextValue
): [
value: string,
onChange: ChangeEventHandler<HTMLInputElement | HTMLTextAreaElement>,
Expand All @@ -24,16 +26,16 @@ function useInputState(

<Interface
name="transformValue"
type="(value: string) => string"
description="입력 값을 변환하는 함수예요. 기본값은 아무 변화 없이 입력을 반환하는 함수예요."
type="(nextValue: string, previousValue: string) => string"
description="입력 값을 변환하는 함수예요. 이전 상태 값이 두 번째 인자로 전달돼요. 기본값은 다음 입력 값을 그대로 반환하는 함수예요."
/>

### 반환 값

<Interface
name=""
type="[value: string, onChange: ChangeEventHandler<HTMLInputElement | HTMLTextAreaElement>]"
description="튜플을 포함해요:"
description="다음 항목을 포함한 튜플이에요:"
:nested="[
{
name: 'value',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ The returned `onChange` handler works with both `<input>` and `<textarea>` eleme
```ts
function useInputState(
initialValue: string | (() => string) = '',
transformValue: (value: string) => string = (v: string) => v
transformValue: (nextValue: string, previousValue: string) => string = (
nextValue: string
) => nextValue
): [
value: string,
onChange: ChangeEventHandler<HTMLInputElement | HTMLTextAreaElement>,
Expand All @@ -25,8 +27,8 @@ function useInputState(

<Interface
name="transformValue"
type="(value: string) => string"
description="A function to transform the input value. Defaults to an identity function that returns the input unchanged."
type="(nextValue: string, previousValue: string) => string"
description="A function to transform the input value. The previous state value is available as its second argument. Defaults to an identity function that returns the next input unchanged."
/>

### Return Value
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createElement } from 'react';
import { ChangeEvent, createElement, StrictMode, useState } from 'react';
import { fireEvent, render } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';

Expand Down Expand Up @@ -67,6 +67,74 @@ describe('useInputState', () => {
expect(input).toHaveValue('MUST BE UPPERCASE');
});

it('should provide the previous value to the transform function', () => {
const { getByRole } = render(
createElement(
createTestInput('value', (nextValue, previousValue) => (nextValue.length > 5 ? previousValue : nextValue))
)
);

const input = getByRole('textbox');
fireEvent.change(input, { target: { value: 'values' } });

expect(input).toHaveValue('value');
});

it('should call transformValue once per change in StrictMode', () => {
const transformValue = vi.fn((nextValue: string) => nextValue);
const { getByRole } = render(createElement(StrictMode, null, createElement(createTestInput('', transformValue))));

fireEvent.change(getByRole('textbox'), { target: { value: 'a' } });

expect(transformValue).toHaveBeenCalledTimes(1);
});

it('should let transformValue update another component', () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});

function Input({ onTooLong }: { onTooLong: (tooLong: boolean) => void }) {
const [, setTouched] = useState(false);
const [value, onChange] = useInputState('', (nextValue, previousValue) => {
onTooLong(nextValue.length > 3);
return nextValue.length > 3 ? previousValue : nextValue;
});

return createElement('input', {
value,
onChange: (event: ChangeEvent<HTMLInputElement>) => {
setTouched(true);
onChange(event);
},
});
}

function Form() {
const [tooLong, setTooLong] = useState(false);
return createElement('div', null, createElement(Input, { onTooLong: setTooLong }), tooLong ? 'too long' : '');
}

const { getByRole, getByText } = render(createElement(Form));
fireEvent.change(getByRole('textbox'), { target: { value: 'abcd' } });

expect(getByText('too long')).toBeInTheDocument();
expect(consoleError).not.toHaveBeenCalled();
consoleError.mockRestore();
});

it('should pass the latest state as previousValue', () => {
const { getByRole } = render(
createElement(
createTestInput('', (nextValue, previousValue) => (nextValue.length > 3 ? previousValue : nextValue))
)
);
const input = getByRole('textbox');

fireEvent.change(input, { target: { value: 'abc' } });
fireEvent.change(input, { target: { value: 'abcd' } });

expect(input).toHaveValue('abc');
});

it('should update value when change event occurs on a textarea', async () => {
const { getByRole } = render(createElement(createTestTextarea()));
const textarea = getByRole('textbox');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ type InitialValue = string | (() => string);
* The returned `onChange` handler works with both `<input>` and `<textarea>` elements.
*
* @param {string | (() => string)} [initialValue=""] - The initial value of the input. Defaults to an empty string (`""`).
* @param {(value: string) => string} [transformValue=(v: string) => v] - A function to transform the input value.
* Defaults to an identity function that returns the input unchanged.
* @param {(nextValue: string, previousValue: string) => string} [transformValue=(nextValue: string) => nextValue]
* - A function to transform the input value. The previous state value is available as its second argument.
* Defaults to an identity function that returns the next input unchanged.
Comment on lines +13 to +15

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The default function’s parameter was renamed in the documentation, but the implementation still uses the old name.

Before

[transformValue=(v: string) => v]

After

[transformValue=(nextValue: string) => nextValue]

Would it make sense to update the implementation for consistency? I think the echo function should be updated as well.

function echo(nextValue: string) {
  return nextValue;
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for pointing this out! 👍

I agree that the implementation should be consistent with the documentation.
I update the parameter name in the default function and the echo function as well.

*
* @returns {[value: string, onChange: ChangeEventHandler<HTMLInputElement | HTMLTextAreaElement>]} A tuple containing:
* - value `string` - The current state value;
Expand All @@ -28,19 +29,22 @@ type InitialValue = string | (() => string);
* );
* }
*/
export function useInputState(initialValue: InitialValue = '', transformValue: (value: string) => string = echo) {
export function useInputState(
initialValue: InitialValue = '',
transformValue: (nextValue: string, previousValue: string) => string = echo
) {
const [value, setValue] = useState(initialValue);

const handleValueChange: ChangeEventHandler<InputLikeElement> = useCallback(
({ target: { value } }: ChangeEvent<InputLikeElement>) => {
setValue(transformValue(value));
({ target: { value: nextValue } }: ChangeEvent<InputLikeElement>) => {
setValue(transformValue(nextValue, value));
},
[transformValue]
[transformValue, value]
);
Comment on lines 38 to 43

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This works well I think. but one thing that im not sure about it

'onChange gets a new reference every time the value change'

(since value is now in deps)

we could keep it stable with 'usePreservedCallback'
but it syncs the latest callback in 'useEffect' so Im a little worried it could have some timing edge case 🤔

what do you think about it?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As you pointed out, the current implementation creates a new onChange reference whenever value changes.

I tried using usePreservedCallback. I didn’t observe an issue during normal typing, but I could reproduce the timing edge case by calling the preserved handler from a useLayoutEffect, before usePreservedCallback updated its callback ref in useEffect.

Here’s the relevant part of the reproduction.

const [value, setValue] = useState('abcd');
const triggered = useRef(false);

const onChange = usePreservedCallback((nextValue: string) => {
  setValue(transformValue(nextValue, value));
});

useLayoutEffect(() => {
  if (value === 'abcde' && !triggered.current) {
    triggered.current = true;
    onChange('abcdef');
  }
}, [value, onChange]);

After typing e, value was abcde when the layout Effect ran, but transformValue received abcd as previousValue.

(This reproduction deliberately calls the handler during a layout Effect. I didn’t observe the issue during normal typing, so I can’t say how likely it is in typical usage.)

Given that result, I think it’s better to accept a new onChange reference when value changes and keep the current implementation so it passes the correct previousValue.


return [value, handleValueChange] as const;
}

function echo(v: string) {
return v;
function echo(nextValue: string) {
return nextValue;
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
```ts
function useInputState(
initialValue: string | (() => string) = '',
transformValue: (value: string) => string = (v: string) => v
transformValue: (nextValue: string, previousValue: string) => string = (
nextValue: string
) => nextValue
): [
value: string,
onChange: ChangeEventHandler<HTMLInputElement | HTMLTextAreaElement>,
Expand All @@ -24,8 +26,8 @@ function useInputState(

<Interface
name="transformValue"
type="(value: string) => string"
description="用于转换输入值的函数。默认值为原样返回输入的恒等函数。"
type="(nextValue: string, previousValue: string) => string"
description="用于转换输入值的函数。第二个参数是更新前的状态值。默认值为原样返回下一个输入值的恒等函数。"
/>

### 返回值
Expand Down
Loading