Some reflections on the process of creating custom components: figuring out where to start + logical sequence of steps to implement, debugging, identifying what can be improved and factors that need to be taken into account. A structural overview of each component, and key takeaways. (To be updated: tags, advanced:autocomplete)
I keep forgetting why exactly I need to use arrow functions when passing event handlers.. the following explains why you need to use them to pass functions with arguments inline (I always revert back to this link every time):
Definition of a controlled component -
...in the controlled component the form input element’s values and mutations are totally driven by event handlers and the value of the input element is always inferred from the state
whereas an uncontrolled component -
doesn't use any states on input elements or any event handler ... doesn't care about an input element’s real-time value changes.
In short, the state of controlled components are controlled by React, vs. the state of uncontrolled components which are controlled by the DOM. Connecting a managed state with input value makes that input value a controlled component. Refs
are used in uncontrolled components to access the input values. Uncontrolled components may be easier to use for smaller applications, but controlled components offer more effective control and consistency which is advantageous for bigger applications. React recommends using controlled components for implementing forms.
When a controlled input value is undefined
, it essentially becomes uncontrolled, which triggers this error:
focus/blur
methodsModalBackdrop
is a parent component to ModalView
in the DOM tree, which is why when the ModalView
is clicked, the event bubbles to ModalBackdrop
and triggers its onClick handler as well. This is problematic since we only want to trigger the handler when the click is detected outside ModalView
. To do this, we need to use the stopPropagation() method within ModalView
's event handler to suppress event bubbling. position: fixed
styled.div.attrs
use caseThere is one state variable isOpen
, and one event handler that toggles this state variable. When you click the ModalBtn
component, its event handler toggles the isOpen
state (turns it on), which then triggers a re-rendering of the Modal
component. Since the state isOpen
changed to true, the button text changes, and the ModalBackdrop
and ModalView
components are displayed. Clicking the x button or anywhere outside the ModalView
window toggles the isOpen
state off, which triggers a re-rendering that hides the modal state view (ModalBackdrop
& ModalView
).
//Styled Component Elements
import { useState } from 'react';
import styled from 'styled-components';
export const ModalContainer = styled.div`
display: flex;
flex-direction: row;
color: black;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
overflow: hidden;
`;
export const ModalBackdrop = styled.div`
display: flex;
align-items: center;
justify-content: center;
position: fixed;
margin: 0;
width: 100vw;
height: 100vh;
background: rgba(48, 48, 48, 0.75);
`;
export const ModalBtn = styled.button`
background-color: var(--coz-purple-600);
text-decoration: none;
border: none;
padding: 20px;
color: white;
border-radius: 30px;
cursor: grab;
`;
export const ModalView = styled.div.attrs((props) => ({
role: 'dialog',
}))`
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 400px;
height: 100px;
border: 1px solid black;
background: white;
border-radius: 25px;
text-decoration: none;
color: black;
`;
export const Modal = () => {
const [isOpen, setIsOpen] = useState(false);
const openModalHandler = () => {
setIsOpen(!isOpen);
};
//you don't need to put another onclick handler in a child component, because it already exists in a parent component!
return ( //clicking backdrop and x closes the window (parent->child propagation), and clicking the dialog box you need to stop event bubbling
<>
<ModalContainer>
<ModalBtn onClick={openModalHandler}>
{isOpen ? 'Opened!' : 'Open Modal'}
</ModalBtn>
{isOpen ? <ModalBackdrop onClick={openModalHandler}>
<ModalView onClick={e => e.stopPropagation()}><div onClick={openModalHandler}>×</div>Hello Codestates!</ModalView>
</ModalBackdrop> : null}
</ModalContainer>
</>
);
};
transition
property inside CSS selectorsThe togglehandler
toggles the isOn
state, which triggers a re-rendering of the component and adds a new class toggle--checked
to the div components, which changes its CSS properties (toggles it on and off.)
import { useState } from 'react';
import styled from 'styled-components';
const ToggleContainer = styled.div`
position: relative;
margin-top: 8rem;
left: 47%;
cursor: pointer;
> .toggle-container {
width: 60px;
height: 30px;
border-radius: 40px;
background: #8b8b8b;
background: linear-gradient(to left, #8b8b8b 50%, var(--coz-purple-600) 50%) right;
background-size: 200%;
transition: .5s ease-out;
${'' /* when toggle--checked is also on, turn this on */}
&.toggle--checked {
background-position: left;
transition: .4s ease-out;
}
}
> .toggle-circle {
position: absolute;
top: 4px;
left: 4px;
width: 22px;
height: 22px;
border-radius: 50%;
background-color: #ffffff;
transition: left 0.5s;
&.toggle--checked{
left: 34px;
transition: left 0.5s ease-out;
}
}
`;
const Desc = styled.div`
display: flex;
margin-top: 20px;
justify-content: center;
color: black;
${'' /* background: pink; */}
${'' /* width: 30%; */}
`;
export const Toggle = () => {
const [isOn, setisOn] = useState(false);
const toggleHandler = () => {
setisOn(!isOn);
};
//onclick, trigger togglehandler
//if toggle is on, add toggle--checked class
//change description text depending on toggle state
return (
<>
<ToggleContainer onClick={toggleHandler}>
<div className={`toggle-container ${isOn ? 'toggle--checked' : ''}`}/>
<div className={`toggle-circle ${isOn ? 'toggle--checked' : ''}`}/>
</ToggleContainer>
{isOn ? <Desc>Toggle Switch On</Desc> : <Desc>Toggle Switch Off</Desc>}
</>
);
};
The menuArr
array contains all the data for the tabs as individual objects with two keys: name, and content. Inside the TabMenu
component, all the tabs are loaded as list items using the map function, each with an onclick handler that changes the currentTab
index when clicked. When the state change triggers a re-rendering, the Desc
component loads the content of the currentTab, and the focused
class is added to the individual tab item that corresponds to the currentTab index (which changes its color.)
The Tabs
component is re-rendered every time a tab is clicked, even when the currently displayed tab is clicked again . This can be revised so that state changes only occur when the clicked tab is different from the currently loaded one, which may decrease some redundant flickering.
menuArr
includes data for all tabs currentTab
(index), updated on click (selectMenuHandler
updates state)TabMenu
(actual tab) & Desc
(tab content)TabMenu
creates the overall tab line, and the class submenu
within it determines the overall style of the individual tabs.focused
Desc
defines the style for the tab content, which is also dependent on the currentTab indexconst TabMenu = styled.ul`
background-color: #dcdcdc;
color: rgba(73, 73, 73, 0.5);
font-weight: bold;
display: flex;
flex-direction: row;
justify-items: center;
align-items: center;
list-style: none;
margin-bottom: 7rem;
.submenu {
padding: 10px 50px 10px 4px;
color: darkgray;
width: calc(100%/3);
}
.focused {
color: white;
background-color: var(--coz-purple-600);
}
& div.desc {
text-align: center;
}
`;
const Desc = styled.div`
text-align: center;
`;
export const Tab = () => {
// manage what tab is currently selected
const [currentTab, setCurrentTab] = useState(0);
const menuArr = [
{ name: 'Tab1', content: 'Tab menu ONE' },
{ name: 'Tab2', content: 'Tab menu TWO' },
{ name: 'Tab3', content: 'Tab menu THREE' },
];
//triggered on clicking a different tab, tab content changes depending on what the currenttab is
const selectMenuHandler = (index) => {
setCurrentTab(index);
};
return (
<>
<div>
<TabMenu>
{/* adds a class depending on whether it is the currentab or not*/}
{/* onclick, the currenttab is updated (passes idx)*/}
{menuArr.map((menu, idx)=> {
return <li key={idx}
className={currentTab === idx ? 'submenu focused' : 'submenu'}
onClick={()=>selectMenuHandler(idx)}
>{menu.name}
</li>;
})}
</TabMenu>
<Desc>
{/* show content of current tab*/}
<p>{menuArr[currentTab].content}</p>
</Desc>
</div>
</>
);
};
The Tags Component loadstags
(initialized with initialTags
) as unordered list items with the map function. Each tag contains a "tag" class, with a "tag-title" and "tag-close-icon" class element stylized via styled components. Clicking the close-icon span element triggers the removeTags
handler which updates the state of tags
(filtering out the clicked element), and this state change triggers a re-rendering to update the view to match the new tags
state. Typing something into the input box updates the newTag
state, and what is typed(newTag
) is added to tags
when the Enter key is clicked (but only if the tag does not already exist in tags
, and is not an empty string. the newTag
state is initialized into an empty string once tag adding is complete.) When the view is updated with the state change, the new tag appears alongside pre-existing ones.
TagsInput
: styled component that defines the following styles: how the tags appear in a row, individual tags when added (including the tag close icon), and input box for creating new tags (including when the box is focused) tags
(current tag list), and newTag
(new tag being made before it is added) newTag
is updated with every key being typed, and only when the Enter key is clicked is the newTag added to the tags
list and then initialized to an empty string tags
is initialized with two strings in initialTags
addTags
& removeTags
addTags
: triggered when Enter key is clicked from input boxnewTag
stateremoveTags
: triggered when x button is clicked. the tag's index is passed to the handler, which uses the index to filter the current tag list (the most compact way of updating current tag list) //Tag.js
import { useState } from 'react';
import styled from 'styled-components';
export const TagsInput = styled.div`
margin: 8rem auto;
display: flex;
align-items: flex-start;
flex-wrap: wrap;
min-height: 48px;
width: 480px;
padding: 0 8px;
border: 1px solid rgb(214, 216, 218);
border-radius: 6px;
> ul {
display: flex;
flex-wrap: wrap;
padding: 0;
margin: 8px 0 0 0;
> .tag {
width: auto;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
padding: 0 8px;
font-size: 14px;
list-style: none;
border-radius: 6px;
margin: 0 8px 8px 0;
background: var(--coz-purple-600);
> .tag-title{
color: white;
}
> .tag-close-icon {
display: block;
width: 16px;
height: 16px;
line-height: 16px;
text-align: center;
font-size: 10px;
margin-left: 8px;
color: var(--coz-purple-600);
border-radius: 50%;
background: #fff;
cursor: pointer;
}
}
}
> input {
flex: 1;
border: none;
height: 46px;
font-size: 14px;
padding: 4px 0 0 0;
:focus {
outline: transparent;
}
}
&:focus-within {
border: 1px solid var(--coz-purple-600);
}
`;
export const Tag = () => {
const initialTags = ['CodeStates', 'kimcoding'];
const [tags, setTags] = useState(initialTags);
const [newTag, setNewTag] = useState('');
//triggered when x is clicked
//copies taglist to avoid directly changing state variable
const removeTags = (indexToRemove) => {
//const tagsCopy = tags.slice();
//tagsCopy.splice(indexToRemove, 1)
//setTags(tagsCopy);
//could use filter instead!
setTags(tags.filter((el, idx) => idx !== indexToRemove))
};
//in addition to adding tags when enter is clicked, this method
//1. checks if the tag already exists before adding the tag
//2. if there is no input, does not add tag
//3. once a tag is added, the input value is emptied
const addTags = (event) => {
if(event.target.value.length){ //when there is no input, no tag added
if(!tags.includes(event.target.value)){
setTags([...tags, event.target.value]);
setNewTag('');
}
}
};
const newTagHandler = e => {
setNewTag(e.target.value);
}
return (
<>
<TagsInput>
<ul id="tags">
{tags.map((tag, index) => (
<li key={index} className="tag">
<span className="tag-title">{tag}</span>
<span className="tag-close-icon" onClick={()=> removeTags(index)}>×</span>
</li>
))}
</ul>
<input
className="tag-input"
type="text"
value={newTag}
onChange={newTagHandler}
onKeyUp={(e) => {
{
if (e.key==='Enter'){
addTags(e);
}
}
}}
placeholder="Press enter to add tags"
/>
</TagsInput>
</>
);
};
useEffect
to initialize dropdown selected state to -1 whenever the input changesuseEffect
to update options as input value changesfilter
instead of pushing related elements in new array (my initial choice) to filter relevant optionshasText
: state for deciding whether to show dropdown or notinputValue
: controlled component options
: state for options relevant to changing input valueonBlur
: event when an input loses focus (e.g. the user clicks somewhere outside the input), can be hooked to an event handler that exits edit mode when this happensWhen the span element is clicked, its event handler handleClick
changes the isEditMode
state to true, and this state change triggers the following:
MyInput
component which then displays the InputEdit
component instead of the span element MyInput
component receives focus, since a useEffect
hook is used to turn the Ref focus on with isEditMode
as a dependencyIn edit mode we can type the text we want into the input, and any changes in input fires the handleInputChange
handler, which updates the state variable newValue
to match what is being typed. To exit edit mode, we can either press and either press the Enter key or click anywhere else with the mouse. Any key or blur event invokes the handleBlur
handler which does the following:
isEditMode
to false (if it's a key event, only when the key is 'Enter')newValue
to handleValueChange
to update the parent component's state variable name
& age
, which triggers a re-rendering of the parent component to reflect the changes in its text description MyInput
child components are then re-rendered to update newValue
to match the parent state variable value through useEffect
MyInput
component (each input component)value
: for initializing newValue(input value state)handleValueChange
: updates state variable managed in parent component to reflect input changes in child componentinputEl
: reference for the input component (to turn its focus on when in edit mode), linked to input ref propertyisEditMode
: switch condition for entering and exiting edit modenewValue
: updated input value, linked to input value propertyhandleClick
: when span is clicked, turns edit mode on handleBlur
: when anywhere else is clicked, turns edit mode off, then updates parent component state to match newValuehandleInputChange
: updates newValue to match changes in inputClickToEdit
Component name
: initialized to cache object's name value, then passed down to MyInput
as props age
: initialized to cache object's age value, then passed down to MyInput
as props handleValueChange
: declared inline, passed down to MyInput
as props to update parent state variables depending on input interactionexport const MyInput = ({ value, handleValueChange }) => {
const inputEl = useRef(null);
const [isEditMode, setEditMode] = useState(false);
const [newValue, setNewValue] = useState(value);
useEffect(() => {
if (isEditMode) {
inputEl.current.focus();
}
}, [isEditMode]);
useEffect(() => {
setNewValue(value);
}, [value]);
const handleClick = () => { //change editState
// console.log('change mode!');
setEditMode(!isEditMode);
};
const handleBlur = (e) => { //change editState
if ( e.key && e.key==="Enter"){
setEditMode(false);
}else if (!e.key){ //if it's not a key event
setEditMode(false);
}
handleValueChange(newValue);
};
const handleInputChange = (e) => { //update value to what's typed
setNewValue(e.target.value);
};
return ( //onClick to the entire InputBox
<InputBox>
{isEditMode ? (
<InputEdit
type='text'
value={newValue}
ref={inputEl} //reference for each input element
onChange={handleInputChange} //when input changes, update state variable
onBlur={handleBlur} //when it loses focus, exit edit mode
onKeyDown={handleBlur} //on clicking enter, exit edit mode
/>
) : (
<span
onClick={handleClick} //when clicked, enter edit mode
>{newValue}</span>
)}
</InputBox>
);
}
const cache = {
name: '김코딩',
age: 20
};
export const ClickToEdit = () => {
const [name, setName] = useState(cache.name);
const [age, setAge] = useState(cache.age);
return (
<>
<InputView>
<label>이름</label>
<MyInput value={name} handleValueChange={(newValue) => setName(newValue)}/>
</InputView>
<InputView>
<label>나이</label>
<MyInput value={age} handleValueChange={(newValue) => setAge(newValue)}/>
</InputView>
<InputView>
<div className='view'>이름 {name} 나이 {age}</div>
</InputView>
</>
);
};
React: Responding to Events
Detect click outside React component
*What are controlled and uncontrolled components in React JS? Which one to use?
Controlled vs uncontrolled React components - why not both?
[TIL] styled-component 활용하기
Styled Components: Basics
12 Coding Examples of Ampersand Usages in Styled Components
Understanding styled components component selector and ampersand (StackOverflow)
Building accessible Select component in React
Codepen: Accessible Select: 4. Keyboard Interactions
Codepen: keyboard-navigable list in React