I have created a Select Dropdown component with its state abstracted in the reducer. I am just updating this collective state using reducer actions and handling the side effects via useEffect
but there are too many useEffects now and made me wonder is this the right approach at all? Also, when I am using reducer actions to update this collective state, are these changes qualified to be called side-effects at all? Sharing two code blocks below:
- List.jsx with logic of the component
- List.reducer.js with reducer logic in it.
List.jsx
import React, { useRef, useCallback, useReducer, useEffect } from "react"
import PropTypes from "prop-types"
import "./list.style.sass"
import ListItem from "../../pure-components/list-item/list-item.jsx"
import TextField from "../../pure-components/text-field/text-field.jsx"
import PrimaryButton from "../../pure-components/primary-button/primary-button.jsx"
import SecondaryButton from "../../pure-components/secondary-button/secondary-button.jsx"
import { useFilterListSearch, useClickOutside, useKeyPress } from "../../hooks"
import { nanoid } from "nanoid"
import { reducer, initialState, ACTION } from "./list.reducer.js"
const List = ({
list,
searchable,
button,
defaultItem,
searchWhichKeys,
onSelect,
}) => {
/**
* State of this component
*/
const [state, dispatch] = useReducer(reducer, initialState)
const filteredList = useFilterListSearch(
list, // Default list
state.searchQuery,
searchWhichKeys
)
const listRef = useRef(null)
const escPress = useKeyPress("Escape")
const closeList = useCallback(
(event) => {
if (event) return
dispatch({ type: ACTION.HIDE_LIST })
},
[state.listVisibility]
)
useClickOutside(listRef, closeList)
const onItemClicked = (item) => {
dispatch({ type: ACTION.HIDE_LIST })
dispatch({ type: ACTION.UPDATE_USER_SELECTION, payload: item })
}
const callbackOnButtonClick = useCallback(
(buttonPressedState, fn) => {
const buttonState = buttonPressedState
? ACTION.SHOW_LIST
: ACTION.HIDE_LIST
console.count([
buttonState,
"because button-pressed is",
buttonPressedState,
fn,
state.buttonUID,
])
dispatch({ type: buttonState })
},
[state.buttonUID]
)
const renderSelectButton = useCallback(() => {
const buttonProps = {
displayTooltip: true,
label: state.userSelection
? state.userSelection.option
: button?.label,
icon: button?.icon,
callback: callbackOnButtonClick,
keepPressedAfterClick: true,
}
return button.type === "primary" ? (
<PrimaryButton key={state.buttonUID} {...buttonProps} />
) : (
<SecondaryButton key={state.buttonUID} {...buttonProps} />
)
}, [state.buttonUID])
const resetButonState = () => {
/**
* Resets the button's state whenever the list's visibility
* is transitoned from shown to hidden. We are not resetting
* the UID in case of state transition from hidden to
* visible as it's resetting button's state on each click
*
* Therefore, if list is visible we will not change the UID only when it
* is made to hide, we will reset the button state as well
*/
if (state.listVisibility) return
dispatch({ type: ACTION.UPDATE_BUTTON_UID, payload: nanoid() })
}
const resetToDefaultList = () => {
/**
* Whenever, we open the list we want it to be the default list
* not filtered one from the previously applied filter (if any)
* Therefore, we will reset it to default whenever it is closed.
* Which is equivalent of setting the user query to null
*/
if (state.listVisibility) return
dispatch({ type: ACTION.RESET_SEARCH_QUERY })
}
useEffect(() => {
/**
* Whenever the default item provided changes, we update
* the userSelectin state with default selection
*/
dispatch({ type: ACTION.UPDATE_USER_SELECTION, payload: defaultItem })
}, [defaultItem])
useEffect(() => {
/**
* Initially we fill the dropdown list with the items in list prop and
* again do the same whenever this provided list changes
*/
dispatch({ type: ACTION.UPDATE_LIST, payload: list })
}, [list])
useEffect(() => {
/**
* We update the ref list state whenever the reference to the list of
* this component changes
*/
dispatch({ type: ACTION.UPDATE_LIST_REF, payload: listRef })
}, [listRef])
useEffect(() => {
/**
* Reset the pressed state fof the button
*/
resetButonState()
/**
* Resets to the default list
*/
resetToDefaultList()
}, [state.listVisibility])
useEffect(() => {
if (state.userSelection === "") return
/**
* Whenever user selects an item we call onSelect callback and
* pass this user selected item as an argument of it
*/
onSelect(state.userSelection)
}, [state.userSelection])
useEffect(() => {
/**
* Whenever custom hook filtered list changes we'll sync
* the resultant list with component's state
*/
dispatch({ type: ACTION.UPDATE_LIST, payload: filteredList })
}, [filteredList])
useEffect(() => {
/**
* If Esc is pressed hide the list else do nothing
*/
if (!escPress) return
dispatch({ type: ACTION.HIDE_LIST })
}, [escPress])
return (
<div className="List" ref={listRef}>
<div className="list-select">{renderSelectButton()}</div>
{state.listVisibility ? (
<div className="list-container">
{searchable ? (
<div className="list-header">
<TextField
name="search"
placeholder="Search..."
onChangeCallback={(event) => {
dispatch({
type: ACTION.UPDATE_SEARCH_QUERY,
payload: event?.target?.value,
})
}}
/>
</div>
) : null}
<div className="list-body">
{state.list.length
? state.list.map((item) => {
return (
<ListItem
key={item.id}
item={item}
isActive={
state.userSelection?.id ===
item?.id
}
onClickCallback={onItemClicked}
/>
)
})
: null}
</div>
</div>
) : null}
</div>
)
}
List.propTypes = {
list: PropTypes.array.isRequired,
searchable: PropTypes.bool,
button: PropTypes.object.isRequired,
defaultItem: PropTypes.object,
searchWhichKeys: PropTypes.array,
onSelect: PropTypes.func,
}
List.defaultProps = {
list: [
{
id: nanoid(),
option: "Anything",
},
],
searchable: false,
button: {
type: "secondary",
icon: null,
label: "Button",
},
defaultItem: null,
searchWhichKeys: ["option"],
onSelect: () => {},
}
export default List
List.reducer.js
/**
* Initial state
*/
export const initialState = {
listVisibility: false,
userSelection: "",
searchQuery: "",
buttonUID: "",
list: [],
refs: {
list: null,
},
error: {
status: false,
message: "",
},
}
/**
* Reducer function
*/
export const reducer = (state, action) => {
switch (action.type) {
case ACTION.SHOW_LIST:
return { ...state, listVisibility: true }
case ACTION.HIDE_LIST:
return { ...state, listVisibility: false }
case ACTION.UPDATE_USER_SELECTION:
return { ...state, userSelection: action.payload }
case ACTION.UPDATE_SEARCH_QUERY:
return { ...state, searchQuery: action.payload }
case ACTION.RESET_SEARCH_QUERY:
return { ...state, searchQuery: "" }
case ACTION.UPDATE_LIST:
return { ...state, list: action.payload }
case ACTION.UPDATE_BUTTON_UID:
return { ...state, buttonUID: action.payload }
case ACTION.UPDATE_LIST_REF:
return {
...state,
refs: {
...state.refs,
list: action.payload,
},
}
case ACTION.THROW_ERROR:
return {
...state,
error: {
...state.error,
status: true,
message: action.payload,
},
}
case ACTION.CLEAR_ERROR:
return {
...state,
error: { ...state.error, status: false, message: "" },
}
default:
throw new Error("Problematic Action: ", action)
}
}
/**
* Reducer Actions
*/
export const ACTION = {
SHOW_LIST: "show-list",
HIDE_LIST: "hide-list",
UPDATE_USER_SELECTION: "update-user-selection",
UPDATE_SEARCH_QUERY: "update-search-query",
RESET_SEARCH_QUERY: "reset-search-query",
UPDATE_LIST: "update-list",
UPDATE_BUTTON_UID: "update-button-uid",
UPDATE_LIST_REF: "update-list-ref",
THROW_ERROR: "throw-error",
CLEAR_ERROR: "clear-error",
}