Diving into Forward Refs

在 React 中,如果要透過 useRef() 取得 component 內的 element,並操作它執行一些行為,如 focus、activate 等,就會需要特殊的處理

在需求方面,我們希望在點選表單送出按鈕時,如果有欄位驗證沒有通過,可以 focus 到該欄位,以供使用者更改輸入內容。

首先,先將 form 表單內重複出現的 input 欄位進行封裝,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// Login.js

const Login = (props) => {
return (
<Card className={classes.login}>
{/* {emailState.value} */}
<form onSubmit={submitHandler}>
<Input
ref={emailInputRef}
isValid={emailIsValid}
id="email"
type="email"
label="E-mail"
value={emailState.val}
onChange={emailChangeHandler}
onBlur={validateEmailHandler}
/>
<Input
ref={passwordInputRef}
isValid={passwordIsValid}
id="password"
type="password"
label="Password"
value={passwordState.val}
onChange={passwordChangeHandler}
onBlur={validatePasswordHandler}
/>
<div className={classes.actions}>
<Button type="submit" className={classes.btn}>
Login
</Button>
</div>
</form>
</Card>
);
};

// Input.js
import React from "react";

const Input = (props) => {
return (
<div
className={`${classes.control} ${
props.isValid === false ? classes.invalid : ""
}`}
>
<label htmlFor={props.id}>{props.label}</label>
<input
type={props.type}
id={props.id}
value={props.value}
onChange={props.onChange}
onBlur={props.onBlur}
/>
</div>
);
};

export default Input;

錯誤方法一

遇到這種需求,可能很直覺的會直接透過 useRef() 取得 input 欄位,再透過 useEffect() 觸發預期的效果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const Input = (props) => {
const inputRef = useRef();

useEffect(() => {
inputRef.current.focus();
}, []);

return (
<div
className={`${classes.control} ${
props.isValid === false ? classes.invalid : ""
}`}
>
<label htmlFor={props.id}>{props.label}</label>
<input
ref={inputRef}
type={props.type}
id={props.id}
value={props.value}
onChange={props.onChange}
onBlur={props.onBlur}
/>
</div>
);
};

但很不幸的是,透過這種直接綁定的方式,還是無法觸發 child component 內的 useEffect()

錯誤方法二

那如果嘗試在 parent component 透過 useRef() 綁定 component,並呼叫由 child component 提供的方法,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
// Input.js
const Input = (props) => {
const inputRef = useRef();

const activate = () => {
inputRef.current.focus();
};

return (
<div
className={`${classes.control} ${
props.isValid === false ? classes.invalid : ""
}`}
>
<label htmlFor={props.id}>{props.label}</label>
<input
ref={inputRef}
type={props.type}
id={props.id}
value={props.value}
onChange={props.onChange}
onBlur={props.onBlur}
/>
</div>
);
};

// Login.js
const Login = (props) => {
const emailInputRef = useRef();
const passwordInputRef = useRef();

const submitHandler = (event) => {
event.preventDefault();
// props.onLogin(enteredEmail, enteredPassword);
if (formIsValid) {
authCtx.onLogin(emailState.value, passwordState.value);
} else if (!emailIsValid) {
emailInputRef.current.activate();
} else {
passwordInputRef.current.activate();
}
};

return (
<Card className={classes.login}>
<form onSubmit={submitHandler}>
<Input
ref={emailInputRef}
isValid={emailIsValid}
id="email"
type="email"
label="E-mail"
value={emailState.val}
onChange={emailChangeHandler}
onBlur={validateEmailHandler}
/>
<Input
ref={passwordInputRef}
isValid={passwordIsValid}
id="password"
type="password"
label="Password"
value={passwordState.val}
onChange={passwordChangeHandler}
onBlur={validatePasswordHandler}
/>
<div className={classes.actions}>
<Button type="submit" className={classes.btn}>
Login
</Button>
</div>
</form>
</Card>
);
};

結果還是沒有預期的效果

解決方式

其實到方法二的實作為止並沒有錯誤,只是還缺少了兩個步驟來解決這個問題

useImperativeHandle()

第一步會需要用到一組新的 React Hooks - useImperativeHandle(),這組 hooks 有兩個參數

  • ref
    這個參數需要帶入的是 component function 的二個參數 ref,如果在 parent component 有透過 useRef() 綁定 component,就可以透過這個參數建立連結

  • callback function
    這個 function 會回傳一個 object,其中的 key 代表 parent component 透過 useRef() 綁定所能呼叫的方法,value 則是 child component 被呼叫後將執行的行為

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// Input.js
const Input = (props, ref) => {
const inputRef = useRef();

const activate = () => {
inputRef.current.focus();
};

useImperativeHandle(ref, () => {
return {
focus: activate,
};
});

return (
<div
className={`${classes.control} ${
props.isValid === false ? classes.invalid : ""
}`}
>
<label htmlFor={props.id}>{props.label}</label>
<input
ref={inputRef}
type={props.type}
id={props.id}
value={props.value}
onChange={props.onChange}
onBlur={props.onBlur}
/>
</div>
);
};

React.forwardRef

最後,如果是這種綁定 child component 特定 element 的需求,還會需要一個步驟是透過 React.forwardRef 來建立這個 child component

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
const Input = React.forwardRef((props, ref) => {
const inputRef = useRef();

const activate = () => {
inputRef.current.focus();
};

useImperativeHandle(ref, () => {
return {
focus: activate,
};
});

return (
<div
className={`${classes.control} ${
props.isValid === false ? classes.invalid : ""
}`}
>
<label htmlFor={props.id}>{props.label}</label>
<input
ref={inputRef}
type={props.type}
id={props.id}
value={props.value}
onChange={props.onChange}
onBlur={props.onBlur}
/>
</div>
);
});

React.ForwardRefRenderFunction

可以做為 Component Function 參數放入 React.forwardRef

1
2
3
4
5
const Input = React.ForwardRefRenderFunction<HTMLDivElement, InputProps> = (props: InputProps, ref) => {
...

return ...;
};

資料參考

React - The Complete Guide (Incl Hooks, React Router, Redux)
GitHub