Stepper functionality using React - reactjs

I'm trying to replicate this stepper like functionality using react.
https://www.commbank.com.au/retail/complaints-compliments-form?ei=CTA-MakeComplaint
Below is my stackblitz, How can I achieve this functionality without using any 3rd Party plugins.
https://stackblitz.com/edit/react-ts-9cfjs3

I set up the basics of the UI on codesandbox.
The main part of how this works is via scrollIntoView using a reference to the div element on each Step. It's important to note that this will work on every modern browser but safari for the smooth scrolling.
Obviously for the actual form parts and moving data around, all of that will still need to be implemented, but this demonstrates nearly all of the navigation/scrolling behaviors as your example.
For reference, here's the main code:
import { useEffect, useRef, useState } from "react";
import A from "./A";
import B from "./B";
import C from "./C";
import D from "./D";
import "./styles.css";
const steps = [A, B, C, D];
export default function App() {
const [step, setStep] = useState(0);
/** Set up a ref that refers to an array, this will be used to hold
* a reference to each step
*/
const refs = useRef<(HTMLDivElement | null)[]>([]);
/** Whenever the step changes, scroll it into view! useEffect needed to wait
* until the new component is rendered so that the ref will properly exist
*/
useEffect(() => {
refs.current[step]?.scrollIntoView({ behavior: "smooth" });
}, [step]);
return (
<div className="App">
{steps
.filter((_, index) => index <= step)
.map((Step, index) => (
<Step
key={index}
/** using `domRef` here to avoid having to set up forwardRef.
* Same behavior regardless, but with less hassle as it's an
* ordianry prop.
*/
domRef={(ref) => (refs.current[index] = ref)}
/** both prev/next handlers for scrolling into view */
toPrev={() => {
refs.current[index - 1]?.scrollIntoView({ behavior: "smooth" });
}}
toNext={() => {
if (step === index + 1) {
refs.current[index + 1]?.scrollIntoView({ behavior: "smooth" });
}
/** This mimics behavior in the reference. Clicking next sets the next step
*/
setStep(index + 1);
}}
/** an override to enable reseting the steps as needed in other ways.
* I.e. changing the initial radio resets to the 0th step
*/
setStep={setStep}
step={index}
/>
))}
</div>
);
}
And component A
import React, { useEffect, useState } from "react";
import { Step } from "./utils";
interface AProps extends Step {}
function A(props: AProps) {
const [value, setValue] = useState("");
const values = [
{ label: "Complaint", value: "complaint" },
{ label: "Compliment", value: "compliment" }
];
const { step, setStep } = props;
useEffect(() => {
setStep(step);
}, [setStep, step, value]);
return (
<div className="step" ref={props.domRef}>
<h1>Component A</h1>
<div>
{values.map((option) => (
<label key={option.value}>
{option.label}
<input
onChange={(ev) => setValue(ev.target.value)}
type="radio"
name="type"
value={option.value}
/>
</label>
))}
</div>
<button
className="next"
onClick={() => {
if (value) {
props.toNext();
}
}}
>
NEXT
</button>
</div>
);
}
export default A;

Related

loading components twice, probably because of useEffect wrong set-up

I have built a ToDo React App (https://codesandbox.io/s/distracted-easley-zjdrkv) that does the following:
User write down an item in the input bar
User hit "enter"
Item is saved into the list below (local storage, will update later)
There is some logic to parse the text and identify tags (basically if the text goes "#tom:buy milk" --> tag=tom, text=buy milk)
The problem I am facing are:
useEffect runs twice at load, and I don't understand why
After the first item gets saved, if I try saving a second item, the app crashes. Not sure why, but I feel it has to do with the point above...and maybe the event listener "onKeyDown"
App
import { useState, useEffect } from 'react'
import './assets/style.css';
import data from '../data/data.json'
import InputBar from "./components/InputBar/InputBar"
import NavBar from "./components/NavBar/NavBar"
import TabItem from "./components/Tab/TabItem"
function App() {
const [dataLoaded, setDataLoaded] = useState(
() => JSON.parse(localStorage.getItem("toDos")) || data
)
useEffect(() => {
localStorage.setItem("toDos", JSON.stringify(dataLoaded))
console.log('update')
}, [dataLoaded])
function deleteItem(id){
console.log(id)
setDataLoaded(oldData=>{
return {
...oldData,
"items":oldData.items.filter(el => el.id !== id)
}
})
}
return (
<div className='container'>
<NavBar/>
<InputBar
setNewList = {setDataLoaded}
/>
{
//Items
dataLoaded.items.map(el=>{
console.log(el)
return <TabItem item={el} key={el.id} delete={deleteItem}/>
})
}
</div>
)
}
export default App
InputBar
import { useState, useEffect } from 'react'
import { nanoid } from 'nanoid'
import '../../assets/style.css';
export default function InputBar(props){
const timeElapsed = Date.now();
const today = new Date(timeElapsed);
function processInput(s) {
let m = s.match(/^(#.+?:)?(.+)/)
if (m) {
return {
tags: m[1] ? m[1].slice(1, -1).split('#') : ['default'],
text: m[2],
created: today.toDateString(),
id:nanoid()
}
}
}
function handleKeyDown(e) {
console.log(e.target.value)
console.log(document.querySelector(".main-input-div input").value)
if(e.keyCode==13){
props.setNewList(oldData =>{
return {
...oldData,
"items" : [processInput(e.target.value), ...oldData.items]
}
}
)
e.target.value=""
}
}
return(
<div className="main-input-div">
<input type="text" onKeyDown={(e) => handleKeyDown(e)}/>
</div>
)
}
Tab
import { useState } from 'react'
import "./tab-item.css"
import { FontAwesomeIcon } from '#fortawesome/react-fontawesome'
import { faTrash } from "#fortawesome/free-solid-svg-icons";
export default function TabItem(props) {
return (
<div className="tab-item">
<div className="tab-item-text">{props.item.text}</div>
<div className="tab-item-actions">
<FontAwesomeIcon icon={faTrash} onClick={()=>props.delete(props.item.id)}/>
</div>
<div className="tab-item-details">
<div className="tab-item-details-tags">
{
props.item.tags.map(el=><div className="tab-item-details-tags-tag">{el}</div>)
}
</div>
</div>
<div className="tab-item-date">{props.item.created}</div>
</div>
)
}
The above answer is almoost correct. I am adding more info to the same concepts.
useEffect running twice:
This is most common ask in recent times. It's because the effect runs twice only in development mode & this behavior is introduced in React 18.0 & above.
The objective is to let the developer see & warn of any bugs that may appear due to a lack of cleanup code when a component unmounts. React is basically trying to show you the complete component mounting-unmounting cycle. Note that this behavior is not applicable in the production environment.
Please check https://beta-reactjs-org-git-effects-fbopensource.vercel.app/learn/synchronizing-with-effects#step-3-add-cleanup-if-needed for a detailed explanation.
App crashes on second time: It's probably because you are trying to update the input value from event.target.value if you want to have control over the input value, your input should be a controlled component meaning, your react code should handle the onChange of input and store it in a state and pass that state as value to the input element & in your onKeyDown handler, reset the value state. That should fix the crash.
export default function InputBar(props){
const [inputVal, setInputVal] = useState("");
function handleKeyDown(e) {
console.log(e.target.value)
console.log(document.querySelector(".main-input-div input").value)
if(e.keyCode==13){
props.setNewList(oldData =>{
return {
...oldData,
"items" : [processInput(e.target.value), ...oldData.items]
}
}
)
setInputVal("")
}
}
return(
<div className="main-input-div">
<input
type="text"
value={inputVal}
onChange={(e) => {setInputVal(e.target.value)}}
onKeyDown={(e) => handleKeyDown(e)}
/>
</div>
)
}
Hope this helps. Cheers!
Your app is using strict mode, which in a development mode renders components twice to help detect bugs (https://reactjs.org/docs/strict-mode.html#detecting-unexpected-side-effects).
root.render(
<StrictMode>
<App />
</StrictMode>
);
As for the crash, I think it's happening due to props.setNewList being an asynchronous call and the resetting of e.target.value - something like this seemed to fix it for me:
function handleKeyDown(e) {
console.log(e.target.value)
console.log(document.querySelector(".main-input-div input").value)
if(e.keyCode==13){
const inputVal = e.target.value;
props.setNewList(oldData =>{
return {
...oldData,
"items" : [processInput(inputVal), ...oldData.items]
}
}
)
e.target.value=""
}
}
I will add, that using document.querySelector to get values isn't typical usage of react, and you might want to look into linking the input's value to a react useState hook.
https://reactjs.org/docs/forms.html#controlled-components

Using generalized components in React

Say I wanted a generalized component for a dropdown like so:
import React, { useState } from "react";
export default function DropDown() {
const [menuOpen, setMenuOpen] = useState(false);
const clickHandler = () => {
setMenuOpen(true);
setTimeout(() => {
setMenuOpen(false);
}, 2000);
props.onClick();
}
return (
<p>Dropdown</p>
{menuOpen && <p>{props.text}</p>}
)
}
I want to then be able to use this dropdown for several components (e.g. a save button, switch between light and dark modes, etc.).
Here's a sample save button:
import React from "react";
import DropDown from "./DropDown";
export default function SaveButton() {
return (
<DropDown text="Save" onClick={() => console.log("Saved")}
)
}
For the light and dark mode button, I would want to console.log whether we're currently on light or dark mode.
import React from "react";
import DropDown from "./DropDown";
export default function LightDark() {
return (
<DropDown text="Save" onClick={() => console.log(menuOpen ? "light" : "dark")}
) // Don't have access to menuOpen
}
I know this is a simple example (the actual code I'm working on involves more complicated casework). But what's the best way to deal with a situation like this, where I want to combine functionality into a general component, but the individual components differ enough that it may be difficult? Here are some of my thoughts: to use casework in generalized component (Case 1), to handle it within the save button or light/dark mode component (Case 2)? Or perhaps another solution?
Case 1 Example:
// DropDown.js
const clickHandler = () => {
setMenuOpen(true);
setTimeout(() => {
setMenuOpen(false);
}, 2000);
if (props.text === "Save") {
// Blah
} else {
// Blah
} // Although this doesn't seem like a good solution for more complicated cases where this generalized component may be used a lot
}
Case 2 Example:
// LightDark.js
import React from "react";
import DropDown from "./DropDown";
export default function LightDark() {
return (
<DropDown text="Save" onClick={(menuOpen) => console.log(menuOpen ? "light" : "dark")} // menuOpen passed from higher order component, also doesn't feel like a good solution when things get complicated
)
}
It may be worth noting that I would like to use functional components.
I understood you want to create a UI component that provides some sort of context menu and can be reused for different actions (e.g. changing the theme, saving/discarding content). Provided my interpretation is correct, the example below might help you to achieve what you want.
Some additional remarks:
I suggest being careful with the term Dropdown in this context as what you're looking for IMO is closer to a context menu
To store the theme (or any value that should be globally available across your app), you'd want to use a React Context rather than a state.
Once you have a good overview over the use cases you'd want to use this component for, you should review the case for re-use and weigh its benefits against the complexity it forces you to introduce
const { useState, useEffect } = React;
const ThemeContext = React.createContext("dark");
const CustomMenu = (props) => {
const [menuOpen, setMenuOpen] = useState(false);
const handleLabelClick = () => {
setMenuOpen(true);
setTimeout(() => {
setMenuOpen(false);
}, 2000);
};
return (
<div>
<button onClick={handleLabelClick}> {props.label} </button>
{menuOpen &&
props.options.map((e) => <p onClick={e.action}> {e.label} </p>)}
</div>
);
};
const App = () => {
const [theme, setTheme] = useState("light");
useEffect(() => {
console.log(`Theme changed to ${theme}`);
}, [theme]);
const themeOptions = [
{
label: "light",
action: () => setTheme("light"),
},
{
label: "dark",
action: () => setTheme("dark"),
},
];
const saveOptions = [
{
label: "save",
action: () => console.log("saved"),
},
{
label: "discard",
action: () => console.log("discarded"),
},
];
return (
<div>
<CustomMenu label="Select Theme" options={themeOptions} />
<CustomMenu label="Action" options={saveOptions} />
</div>
);
};
ReactDOM.render(<App />, document.getElementById("root"));
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/17.0.1/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/17.0.1/umd/react-dom.production.min.js"></script>
<div id="root"></div>

input value not updating when mutating state

While creating a little project for learning purposes I have come across an issue with the updating of the input value. This is the component (I have tried to reduce it to a minimum).
function TipSelector({selections, onTipChanged}: {selections: TipSelectorItem[], onTipChanged?:(tipPercent:number)=>void}) {
const [controls, setControls] = useState<any>([]);
const [tip, setTip] = useState<string>("0");
function customTipChanged(percent: string) {
setTip(percent);
}
//Build controls
function buildControls()
{
let controlList: any[] = [];
controlList.push(<input className={styles.input} value={tip.toString()} onChange={(event)=> {customTipChanged(event.target.value)}}></input>);
setControls(controlList);
}
useEffect(()=>{
console.log("TipSelector: useEffect");
buildControls();
return ()=> {
console.log("unmounts");
}
},[])
console.log("TipSelector: Render -> "+tip);
return (
<div className={styles.tipSelector}>
<span className={globalStyles.label}>Select Tip %</span>
<div className={styles.btnContainer}>
{
controls
}
</div>
</div>
);
}
If I move the creation of the input directly into the return() statement the value is updated properly.
I'd move your inputs out of that component, and let them manage their own state out of the TipSelector.
See:
https://codesandbox.io/s/naughty-http-d38w9
e.g.:
import { useState, useEffect } from "react";
import CustomInput from "./Input";
function TipSelector({ selections, onTipChanged }) {
const [controls, setControls] = useState([]);
//Build controls
function buildControls() {
let controlList = [];
controlList.push(<CustomInput />);
controlList.push(<CustomInput />);
setControls(controlList);
}
useEffect(() => {
buildControls();
return () => {
console.log("unmounts");
};
}, []);
return (
<div>
<span>Select Tip %</span>
<div>{controls}</div>
</div>
);
}
export default TipSelector;
import { useState, useEffect } from "react";
function CustomInput() {
const [tip, setTip] = useState("0");
function customTipChanged(percent) {
setTip(percent);
}
return (
<input
value={tip.toString()}
onChange={(event) => {
customTipChanged(event.target.value);
}}
></input>
);
}
export default CustomInput;
You are only calling buildControls once, where the <input ... gets its value only that single time.
Whenever React re-renders your component (because e.g. some state changes), your {controls} will tell React to render that original <input ... with the old value.
I'm not sure why you are storing your controls in a state variable? There's no need for that, and as you noticed, it complicates things a lot. You would basically require a renderControls() function too that you would replace {controls} with.

Show loading state but also show previous results in React Concurrent gives a warning

UPDATE: Ok, it I misunderstood useDeferredValue, I thought it was more like a debounced value but it's not, you can define the timeout to be the time the old results will be shown.
So
const search = useDeferredValue(value, { timeoutMs: 10000 })
Gave me the desired effect, only it still show the warning right know.
Original
I want to have a search with the results below it, the search result should filter immediately based on the input of the text field. Then the query should be done debounced and the old results should show also when it takes less than e.g. 3000 m.s.
I'm working with the new concurrent mode in React and Relay experimental. I used the new useDeferredValue, documented on this page: https://reactjs.org/docs/concurrent-mode-reference.html#usetransition
But I got this warning:
Warning: Asynchronous triggered a user-blocking update that suspended.
The fix is to split the update into multiple parts: a user-blocking update to provide immediate feedback, and another update that triggers the bulk of the changes.
Refer to the documentation for useTransition to learn how to implement this pattern
I don't get this since it works but it still gives me a warning.
My code:
import React, {
Suspense,
useState,
// #ts-ignore - useDeferredValue does not exist yet in types
useDeferredValue,
// #ts-ignore - useDeferredValue does not exist yet in types
// useTransition,
useCallback,
ChangeEvent,
} from 'react'
import TextField from '#material-ui/core/TextField'
import LinearProgress from '#material-ui/core/LinearProgress'
import { graphql } from 'babel-plugin-relay/macro'
import { useLazyLoadQuery } from 'react-relay/hooks'
import {
FlowBlockFinderQuery,
FlowBlockFinderQueryResponse,
} from '../__generated__/FlowBlockFinderQuery.graphql'
import ErrorBoundaryWithRetry from '../helpers/ErrorBoundaryWithRetry'
interface RenderFuncProps {
search: string
filterSearch: string
}
function QueryResults({ search, filterSearch }: RenderFuncProps) {
const { blocks }: FlowBlockFinderQueryResponse = useLazyLoadQuery<
FlowBlockFinderQuery
>(
graphql`
query FlowBlockFinderQuery($search: String) {
blocks(search: $search) {
id
title
description
slug
blockType
}
}
`,
{ search },
{ fetchPolicy: 'store-or-network' }
)
return (
<div>
{blocks
.filter(
block =>
!filterSearch ||
block.title.toLowerCase().includes(filterSearch.toLowerCase())
)
.map(block => (
<div key={block.id} style={{ fontSize: 19 }}>
{block.title}
</div>
))}
</div>
)
}
function Results({ search, filterSearch }: RenderFuncProps) {
return (
<>
Zoekterm: {filterSearch}
<ErrorBoundaryWithRetry
fallback={({ error }) => <div>Er is iets foutgegaan</div>}
>
<Suspense fallback={<LinearProgress />}>
<QueryResults search={search} filterSearch={filterSearch} />
</Suspense>
</ErrorBoundaryWithRetry>
</>
)
}
export default function Asynchronous() {
const [value, setValue] = useState('')
// const [search, setSearch] = useState('')
const search = useDeferredValue(value, { timeoutMs: 3000 })
// const [startTransition, isPending] = useTransition(SUSPENSE_CONFIG)
const onInputChange = useCallback(
(event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
// startTransition(() => {
setValue(event.currentTarget.value)
// })
},
[setValue]
)
return (
<div style={{ display: 'flex', flexDirection: 'column' }}>
<TextField
label="Nieuw of bestaand blok"
fullWidth
variant="outlined"
value={value}
onChange={onInputChange}
/>
<br />
<Results search={search} filterSearch={value} />
</div>
)
}
React docs "if some state update causes a component to suspend, that state update should be wrapped in a transition". You have to make the async request suspense compatible and fetch the query in useTransition.
Here is an example from react docs
function handleChange(e) {
const value = e.target.value;
// Outside the transition (urgent)
setQuery(value);
startTransition(() => {
// Inside the transition (may be delayed)
setResource(fetchTranslation(value));
});
}
And the link to code sandbox

UseEffect causes infinite loop with swipeable routes

I am using the https://www.npmjs.com/package/react-swipeable-routes library to set up some swipeable views in my React app.
I have a custom context that contains a dynamic list of views that need to be rendered as children of the swipeable router, and I have added two buttons for a 'next' and 'previous' view for desktop users.
Now I am stuck on how to get the next and previous item from the array of modules.
I thought to fix it with a custom context and custom hook, but when using that I am getting stuck in an infinite loop.
My custom hook:
import { useContext } from 'react';
import { RootContext } from '../context/root-context';
const useShow = () => {
const [state, setState] = useContext(RootContext);
const setModules = (modules) => {
setState((currentState) => ({
...currentState,
modules,
}));
};
const setActiveModule = (currentModule) => {
// here is the magic. we get the currentModule, so we know which module is visible on the screen
// with this info, we can determine what the previous and next modules are
const index = state.modules.findIndex((module) => module.id === currentModule.id);
// if we are on first item, then there is no previous
let previous = index - 1;
if (previous < 0) {
previous = 0;
}
// if we are on last item, then there is no next
let next = index + 1;
if (next > state.modules.length - 1) {
next = state.modules.length - 1;
}
// update the state. this will trigger every component listening to the previous and next values
setState((currentState) => ({
...currentState,
previous: state.modules[previous].id,
next: state.modules[next].id,
}));
};
return {
modules: state.modules,
setActiveModule,
setModules,
previous: state.previous,
next: state.next,
};
};
export default useShow;
My custom context:
import React, { useState } from 'react';
export const RootContext = React.createContext([{}, () => {}]);
export default (props) => {
const [state, setState] = useState({});
return (
<RootContext.Provider value={[state, setState]}>
{props.children}
</RootContext.Provider>
);
};
and here the part where it goes wrong, in my Content.js
import React, { useEffect } from 'react';
import { Route } from 'react-router-dom';
import SwipeableRoutes from 'react-swipeable-routes';
import useShow from '../../hooks/useShow';
import NavButton from '../NavButton';
// for this demo we just have one single module component
// when we have real data, there will be a VoteModule and CommentModule at least
// there are 2 important object given to the props; module and match
// module comes from us, match comes from swipeable views library
const ModuleComponent = ({ module, match }) => {
// we need this function from the custom hook
const { setActiveModule } = useShow();
// if this view is active (match.type === 'full') then we tell the show hook that
useEffect(() => {
if (match.type === 'full') {
setActiveModule(module);
}
},[match]);
return (
<div style={{ height: 300, backgroundColor: module.title }}>{module.title}</div>
);
};
const Content = () => {
const { modules, previousModule, nextModule } = useShow();
// this is a safety measure, to make sure we don't start rendering stuff when there are no modules yet
if (!modules) {
return <div>Loading...</div>;
}
// this determines which component needs to be rendered for each module
// when we have real data we will switch on module.type or something similar
const getComponentForModule = (module) => {
// this is needed to get both the module and match objects inside the component
// the module object is provided by us and the match object comes from swipeable routes
const ModuleComponentWithProps = (props) => (
<ModuleComponent module={module} {...props} />
);
return ModuleComponentWithProps;
};
// this renders all the modules
// because we return early if there are no modules, we can be sure that here the modules array is always existing
const renderModules = () => (
modules.map((module) => (
<Route
path={`/${module.id}`}
key={module.id}
component={getComponentForModule(module)}
defaultParams={module}
/>
))
);
return (
<div className="content">
<div>
<SwipeableRoutes>
{renderModules()}
</SwipeableRoutes>
<NavButton type="previous" to={previousModule} />
<NavButton type="next" to={nextModule} />
</div>
</div>
);
};
export default Content;
For sake of completion, also my NavButton.js :
import React from 'react';
import { NavLink } from 'react-router-dom';
const NavButton = ({ type, to }) => {
const iconClassName = ['fa'];
if (type === 'next') {
iconClassName.push('fa-arrow-right');
} else {
iconClassName.push('fa-arrow-left');
}
return (
<div className="">
<NavLink className="nav-link-button" to={`/${to}`}>
<i className={iconClassName.join(' ')} />
</NavLink>
</div>
);
};
export default NavButton;
In Content.js there is this part:
// if this view is active (match.type === 'full') then we tell the show hook that
useEffect(() => {
if (match.type === 'full') {
setActiveModule(module);
}
},[match]);
which is causing the infinite loop. If I comment out the setActiveModule call, then the infinite loop is gone, but of course then I also won't have the desired outcome.
I am sure I am doing something wrong in either the usage of useEffect and/or the custom hook I have created, but I just can't figure out what it is.
Any help is much appreciated
I think it's the problem with the way you are using the component in the Route.
Try using:
<Route
path={`/${module.id}`}
key={module.id}
component={() => getComponentForModule(module)}
defaultParams={module}
/>
EDIT:
I have a feeling that it's because of your HOC.
Can you try
component={ModuleComponent}
defaultParams={module}
And get the module from the match object.
const ModuleComponent = ({ match }) => {
const {type, module} = match;
const { setActiveModule } = useShow();
useEffect(() => {
if (type === 'full') {
setActiveModule(module);
}
},[module, setActiveModule]);
match is an object and evaluated in the useEffect will always cause the code to be executed. Track match.type instead. Also you need to track the module there. If that's an object, you'll need to wrap it in a deep compare hook: https://github.com/kentcdodds/use-deep-compare-effect
useEffect(() => {
if (match.type === 'full') {
setActiveModule(module);
}
},[match.type, module]);

Resources