My friend and I are working on a React translator app that uses the Lexicala API. We have two selector components: one for the source language and one for the target language. Users will first select the source language, and based on what they choose, a second dropdown menu will populate with a list of target languages that are available. Does anyone have any suggestions for how we would make the state update in the source language selector component affect the second component?
I'm including the code (without the comments) for each component. If you think we're already doing something incorrectly, please let me know.
SourceLanguageSelector.js
import React, {useState, useEffect} from 'react';
import { encode } from "base-64";
let headers = new Headers();
headers.append('Authorization', 'Basic ' + encode(process.env.REACT_APP_API_USERNAME + ":" + process.env.REACT_APP_API_PASSWORD));
const SourceLanguageSelector = () => {
const [loading, setLoading] = useState(true);
const [items, setItems] = useState([
{ label: "Loading...", value: "" }
]);
const [value, setValue] = useState();
useEffect(() => {
let unmounted = false;
async function getLanguages() {
const request = await fetch("https://dictapi.lexicala.com/languages", {
method: 'GET', headers: headers
});
const body = await request.json();
console.log(body);
const sourceLang = body.resources.global.source_languages;
const langName = body.language_names;
const compare = (sourceLanguage, languageName) => {
return sourceLanguage.reduce((obj, key) => {
if (key in languageName) {
obj[key] = languageName[key];
}
return obj;
}, {});
}
const sourceLanguageNames = compare(sourceLang, langName);
if (!unmounted) {
setItems(
Object.values(sourceLanguageNames).map((sourceLanguageName) => ({
label: sourceLanguageName,
value: sourceLanguageName
}))
);
setLoading(false);
}
}
getLanguages();
return () => {
unmounted = true;
}
}, []);
return (
<select
disabled={loading}
value={value}
onChange={e => setValue(e.currentTarget.value)}>
{items.map(item => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
);
};
export default SourceLanguageSelector;
TargetLanguageSelector.js
import React, { useState, useEffect } from 'react';
import { encode } from 'base-64';
let headers = new Headers();
headers.append('Authorization', 'Basic ' + encode(process.env.REACT_APP_API_USERNAME + ":" + process.env.REACT_APP_API_PASSWORD));
const TargetLanguageSelector = () => {
const [loading, setLoading] = useState(true);
const [items, setItems] = useState([
{ label: "Loading...", value: "" }
]);
const [value, setValue] = useState();
useEffect(() => {
let unmounted = false;
async function getLanguages() {
const request = await fetch("https://dictapi.lexicala.com/languages", {
method: 'GET', headers: headers
});
const body = await request.json();
console.log(body);
const targetLang = body.resources.global.target_languages;
const langName = body.language_names;
const compare = (targetLanguage, languageName) => {
return targetLanguage.reduce((obj, key) => {
if (key in languageName) {
obj[key] = languageName[key];
}
return obj;
}, {});
}
const targetLanguageNames = compare(targetLang, langName);
if (!unmounted) {
setItems(
Object.values(targetLanguageNames).map(target_languages =>
({
label: target_languages,
value: target_languages
}))
);
setLoading(false);
}
}
getLanguages();
return () => {
unmounted = true;
}
}, []);
return (
<select
disabled={loading}
value={value}
onChange={e => setValue(e.currentTarget.value)}>
{items.map(item => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
);
};
export default TargetLanguageSelector;
You can coordinate state between the selects through the parent. Pass a callback function to Source that gets called when it's onChange handler fires, passing the selected language up to the parent. In the parent, create a piece of state for the current selected language and pass it as a prop to Target. In target you can have a useEffect with that prop in the dependency array so whenever the selected language changes in Source, Target will make the appropriate api call.
You will have three options to have connection between two separate components.
Make parent as the connector between two children.
e.g, Use a function props to trigger handler in parent.
This handler will change the state of parent and it will lead to change the
props of another children like how #EvanMorrison has answered.
You can use a global state tree , a state management library like Redux, MobX or Flux which will help you manage a single source of truth. Therefore, when you dispatch an action in one component, the state can be detected at another component and then use an useEffect hook to trigger again using the state as a dependency.
Another pattern is to use Context API, but even for me, I rarely use it although understand the concepts.
You can find more explanation and examples here.
https://reactjs.org/docs/context.html#updating-context-from-a-nested-component
React.js - Communicating between sibling components
Related
I am wondering why the console.log({ routes }) is outputting on a search useState update without the search having filtered the data. I have memoized and useCallbacked things.
import { memo, useCallback, useMemo, useState } from 'react'
import * as stations from './stations.json'
import './App.css';
const StyledContainer = {
"cursor": "pointer",
"display": "flex",
"flex-direction": "row"
}
const StyledSegment = {
"justify-content": "space-between",
"text-align": "left",
"width": "33.3%"
}
const baseUrl = 'https://api.wheresthefuckingtrain.com/by-id'
const StationComponent = (fetchSchedule, s) => {
const { id, location, name, routes = false, stops } = s
console.log({ routes }) // THIS RERENDERS
return (
<div key={id} onClick={() => { fetchSchedule(id) }}>
<h3>{name}</h3>
<h4>{location}</h4>
{routes && routes.N.length > 0 && <div><label>North</label><ul>{routes.N.map(({ route, time }, idx) => <li key={idx}>Route: {route}<br></br>{new Date(time).toLocaleTimeString()}</li>)}</ul></div>}
{routes && routes.N.length > 0 && <div><label>South</label><ul>{routes.S.map(({ route, time }, idx) => <li key={idx}>Route: {route}<br></br>{new Date(time).toLocaleTimeString()}</li>)}</ul></div>}
</div>
)
}
const MemoizedStationComponent = memo(StationComponent)
function App() {
const [filteredStationData, setFilteredStationData] = useState(stations)
const [search, setSearch] = useState() // station name
const [stationData, setStationData] = useState(stations)
console.log({stations})
const fetchSchedule = useCallback(async (id) => {
const res = await fetch(`${baseUrl}/${id}`, { method: 'get' })
const json = await res.json()
console.log("apiCall")
const copyStationData = JSON.parse(JSON.stringify(stations))
copyStationData[id].routes = json.data[0]
setStationData(copyStationData)
})
const filterStations = useCallback(() => {
const filteredStations = Object.values(stationData).filter((s) => s.name.includes(search))
setFilteredStationData(filteredStations)
})
return (
<div className="App" style={StyledContainer}>
<header className="App-header">
<input placeholder="Name" onChange={(e) => { setSearch(e.target.value) }} /><button onClick={() => { filterStations() }}>Search</button>
<div style={StyledSegment}>
{Object.values(filteredStationData).map(s => <MemoizedStationComponent fetchSchedule={fetchSchedule} s={s} />)}
</div>
</header>
</div>
);
}
export default App;
Few points:
You haven't provided dependencies to useCallback.
Pass an inline callback and an array of dependencies. useCallback will
return a memoized version of the callback that only changes if one of
the dependencies has changed. This is useful when passing callbacks to
optimized child components that rely on reference equality to prevent
unnecessary renders (e.g. shouldComponentUpdate).
Also I notice you haven't passed keys here: <MemoizedStationComponent fetchSchedule={fetchSchedule} s={s} />
; this can become problematic (you should have received warning), and you can end up in corrupt state etc., if the elements of the array which you are mapping can reorder for example.
And general note, if you pass an object as prop to memoized component, make sure it is not recreated on each render, or it will break memoization.
Just add dependencies for useCallback to prevent unnecessary re-renders of your station component.
const fetchSchedule = useCallback(async (id) => {
const res = await fetch(`${baseUrl}/${id}`, { method: 'get' })
const json = await res.json()
console.log("apiCall")
const copyStationData = JSON.parse(JSON.stringify(stations))
copyStationData[id].routes = json.data[0]
setStationData(copyStationData)
}, [stations])
const filterStations = useCallback(() => {
const filteredStations = Object.values(stationData).filter((s) => s.name.includes(search))
setFilteredStationData(filteredStations)
}, [search])
Now each time your stations or search value is changed. Your component will re-render.
I have a dropdown menu showing states and counties. I want the county one to be dependent on the state one.
I am using react, javascript, prisma to access the database.
I made it work separated, so I can get the states to show and the counties, but I don't know how to make them dependent.
What I think I need is a way to change my function that bring the county data. I can group by the state that was selected. So what I need is after getting the state that was selected to send that to my "byCounty" function. Is that possible?
menu.js
export default function DropDownMenu(props){
if(!props.states) return
return(
<table>
<body>
<select onChange={(e) => { console.log(e.target.value) }}>
{props.states.map(states=>
<option>{states.state}</option>
)}
</select>
<select >
{props.byCounty.map(byCounty=>
<option>{byCounty.county}</option>
)}
</select>
</body>
</table>
)
}
functions.js
const states = await prisma.county.groupBy({
by:["state"],
where: {
date: dateTime,
},
_sum:{
cases:true,
},
});
const byCounty = await prisma.county.groupBy({
by:["county"],
where: {
date: dateTime,
state: 'THIS SHOULD BE THE STATE NAME SELECTED BY USER'
},
_sum:{
cases:true,
},
});
const result =JSON.stringify(
{states:states, byCounty:byCounty},
(key, value) => (typeof value === 'bigint' ? parseInt(value) : value) // return everything else unchanged
)
res.json(result);
index.js
<div className={styles.table_container}>
<h2>Teste</h2>
<DropDownMenu states={myData?myData.states:[]} byCounty={myData?myData.byCounty:[]}></DropDownMenu>
</div>
What I have:
Here's a self-contained example demonstrating how to "fetch" options from a mock API (async function), and use the results to render a top level list of options, using the selected one to do the same for a dependent list of options. The code is commented, and I can explain further if anything is unclear.
For simplicity, the example doesn't use states and counties, but the dependency relationship is the same.
TS Playground
body { font-family: sans-serif; }
.select-container { display: flex; gap: 1rem; }
select { font-size: 1rem; padding: 0.25rem; }
<div id="root"></div><script src="https://unpkg.com/react#18.1.0/umd/react.development.js"></script><script src="https://unpkg.com/react-dom#18.1.0/umd/react-dom.development.js"></script><script src="https://unpkg.com/#babel/standalone#7.17.10/babel.min.js"></script><script>Babel.registerPreset('tsx', {presets: [[Babel.availablePresets['typescript'], {allExtensions: true, isTSX: true}]]});</script>
<script type="text/babel" data-type="module" data-presets="tsx,react">
// import * as ReactDOM from 'react-dom/client';
// import {
// type Dispatch,
// type ReactElement,
// type SetStateAction,
// useEffect,
// useRef,
// useState,
// } from 'react';
// This Stack Overflow snippet demo uses UMD modules instead of the above import statments
const {
useEffect,
useRef,
useState,
} = React;
// The next section is just a mock API for getting dependent options (like your States/Counties example):
async function getOptionsApi (level: 1): Promise<string[]>;
async function getOptionsApi (
level: 2,
level1Option: string,
): Promise<string[]>;
async function getOptionsApi (
level: 1 | 2,
level1Option?: string,
) {
const OPTIONS: Record<string, string[]> = {
colors: ['red', 'green', 'blue'],
numbers: ['one', 'two', 'three'],
sizes: ['small', 'medium', 'large'],
};
if (level === 1) return Object.keys(OPTIONS);
else if (level1Option) {
const values = OPTIONS[level1Option];
if (!values) throw new Error('Invalid level 1 option');
return values;
}
throw new Error('Invalid level 1 option');
}
// This section includes the React components:
type SelectInputProps = {
options: string[];
selectedOption: string;
setSelectedOption: Dispatch<SetStateAction<string>>;
};
function SelectInput (props: SelectInputProps): ReactElement {
return (
<select
onChange={(ev) => props.setSelectedOption(ev.target.value)}
value={props.selectedOption}
>
{props.options.map((value, index) => (
<option key={`${index}.${value}`} {...{value}}>{value}</option>
))}
</select>
);
}
function App (): ReactElement {
// Use a ref to track whether or not it's the initial render
const isFirstRenderRef = useRef(true);
// State for storing the top level array of options
const [optionsLvl1, setOptionsLvl1] = useState<string[]>([]);
const [selectedLvl1, setSelectedLvl1] = useState('');
// State for storing the options that depend on the selected value from the level 1 options
const [optionsLvl2, setOptionsLvl2] = useState<string[]>([]);
const [selectedLvl2, setSelectedLvl2] = useState('');
// On the first render only, get the top level options from the "API"
// and set the selected value to the first one in the list
useEffect(() => {
const setOptions = async () => {
const opts = await getOptionsApi(1);
setOptionsLvl1(opts);
setSelectedLvl1(opts[0]!);
};
if (isFirstRenderRef.current) {
isFirstRenderRef.current = false;
setOptions();
}
}, []);
// (Except for the initial render) every time the top level option changes,
// get the dependent options from the "API" and set
// the selected dependent value to the first one in the list
useEffect(() => {
const setOptions = async () => {
const opts = await getOptionsApi(2, selectedLvl1);
setOptionsLvl2(opts);
setSelectedLvl2(opts[0]!);
};
if (isFirstRenderRef.current) return;
setOptions();
}, [selectedLvl1]);
return (
<div>
<h1>Dependent select options</h1>
<div className="select-container">
<SelectInput
options={optionsLvl1}
selectedOption={selectedLvl1}
setSelectedOption={setSelectedLvl1}
/>
<SelectInput
options={optionsLvl2}
selectedOption={selectedLvl2}
setSelectedOption={setSelectedLvl2}
/>
</div>
</div>
);
}
const reactRoot = ReactDOM.createRoot(document.getElementById('root')!)
reactRoot.render(<App />);
</script>
You could use custom hooks to do this.
The key is that in your code the second dropdown should watch the changes in the date of the first dropdown & react to these changes. In React you do this by using useEffect() (most of the times):
useEffect(() => {
reactingToChanges()
}, [watchedVariable])
In the snippet,
The "states" API is querying a real source of data
I mocked the counties API (I couldn't find a free/freesource solution)
I added a simple cache mechanism for the counties, so the API doesn't get queried if the data has already been downloaded
// THE IMPORTANT PART IS IN A COMMENT TOWARDS THE BOTTOM
const { useEffect, useState } = React;
const useFetchStates = () => {
const [states, setStates] = useState([]);
const fetchStates = () => {
const myHeaders = new Headers();
myHeaders.append("Content-Type", "application/x-www-form-urlencoded");
const urlencoded = new URLSearchParams();
urlencoded.append("iso2", "US");
const requestOptions = {
method: "POST",
headers: myHeaders,
body: urlencoded,
redirect: "follow"
};
fetch(
"https://countriesnow.space/api/v0.1/countries/states",
requestOptions
)
.then((response) => response.json())
.then(({ data: { states } }) => setStates(states))
.catch((error) => console.log("error", error));
};
if (!states.length) {
fetchStates();
}
return {
states
};
};
const useFetchCounties = () => {
const [countiesByState, setCountiesByState] = useState({});
const [counties, setCounties] = useState([]);
const fetchCounties = (state) => {
if (state in countiesByState) {
setCounties(countiesByState[state]);
} else if (state) {
fetch("https://jsonplaceholder.typicode.com/todos")
.then((response) => response.json())
.then((json) => {
const mappedCounties = json.map(({ id, title }) => ({
id: `${state}-${id}`,
title: `${state} - ${title}`
}));
setCounties(mappedCounties);
setCountiesByState((prevState) => ({
...prevState,
[state]: mappedCounties
}));
});
} else {
setCounties([]);
}
};
return {
counties,
fetchCounties
};
};
const Selector = ({ options = [], onChange, dataType }) => {
return (
<select onChange={(e) => onChange(e.target.value)} defaultValue={"DEFAULT"}>
<option disabled value="DEFAULT">
SELECT {dataType}
</option>
{options.map(({ name, val }) => (
<option key={val} value={val}>
{name}
</option>
))}
</select>
);
};
const App = () => {
const { states = [] } = useFetchStates();
const [selectedState, setSelectedState] = useState("");
const { counties, fetchCounties } = useFetchCounties();
const [selectedCounty, setSelectedCounty] = useState("");
// here's the heart of this process, the useEffect():
// when the selectedState variable changes, the
// component fetches the counties (based on currently
// selected state) and resets the currently selected
// county (as we do not know that at this time)
useEffect(() => {
fetchCounties(selectedState);
setSelectedCounty("");
}, [selectedState]);
const handleSelectState = (val) => setSelectedState(val);
const handleSelectCounty = (val) => setSelectedCounty(val);
return (
<div>
<Selector
options={states.map(({ name, state_code }) => ({
name,
val: state_code
}))}
onChange={handleSelectState}
dataType={"STATE"}
/>
<br />
<Selector
options={counties.map(({ id, title }) => ({
name: title,
val: id
}))}
onChange={handleSelectCounty}
dataType={"COUNTY"}
/>
<br />
Selected state: {selectedState}
<br />
Selected county: {selectedCounty}
</div>
);
};
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(<App />);
<script crossorigin src="https://unpkg.com/react#18/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom#18/umd/react-dom.production.min.js"></script>
<div id="root"></div>
The way you asked the question leads to different interpretations of your problem, both #muka.gergely's and #jsejcksn's answers are very good solutions but it's much more from what you really asked for. As you only want to get the value from selected state and fetch the counties from your backend, you can do the following:
functions.js
// change to a function that gets a state as parameter
const byCounty = async (selectedState) => {
return await prisma.county.groupBy({
by:["county"],
where: {
date: dateTime,
// use the received parameter here to fetch the counties
state: selectedState
},
_sum:{
cases:true,
},
})
};
menu.js
export default function DropDownMenu(props){
if(!props.states) return
return(
<table>
<body>
<select
// use the byCounty function with the selected value to fetch the counties
onChange={ async (e) => {
await byCounty(e.target.value)
}}
>
{props.states.map(states=>
<option>{states.state}</option>
)}
</select>
<select >
{props.byCounty.map(byCounty=>
<option>{byCounty.county}</option>
)}
</select>
</body>
</table>
)
}
And that's all, if you want to make the option county and state working together you can use the idea behind the other answers as well. Hope I helped you!
This is exactly what I'm wanting to do. . . For example, First drop down list would list all the State Names, then I click on that state, and it would generate a text file. The text file would be county name placefiles for all the counties for that state.
const BankSearch = ({ banks, searchCategory, setFilteredBanks }) => {
const [searchString, setSearchString] = useState();
const searchBanks = (search) => {
const filteredBanks = [];
banks.forEach((bank) => {
if (bank[searchCategory].toLowerCase().includes(search.toLowerCase())) {
console.log(bank[searchCategory].toLowerCase());
filteredBanks.push(bank);
}
});
setFilteredBanks(filteredBanks);
};
const debounceSearch = useCallback(_debounce(searchBanks, 500), []);
useEffect(() => {
if (searchString?.length) {
debounceSearch(searchString);
} else setFilteredBanks([]);
}, [searchString, searchCategory]);
const handleSearch = (e) => {
setSearchString(e.target.value);
};
return (
<div className='flex'>
<Input placeholder='Bank Search' onChange={handleSearch} />
</div>
);
};
export default BankSearch;
filteredBanks state is not updating
banks is a grandparent state which has a lot of objects, similar to that is filteredBanks whose set method is being called here which is setFilteredBanks
if I add a console log and save or remove it the state updates
Adding or removing the console statement and saving the file, renders the function again, the internal function's state is updated returned with the (setState) callback.
(#vnm)
Adding filteredBanks to your dependency array won't do much because it is part of the lexical scope of the function searchBanks
I'm not entirely sure of the total context of this BankSearch or what it should be. What I do see is that there are some antipatterns and missing dependencies.
Try this:
export default function BankSearch({ banks, searchCategory, setFilteredBanks }) {
const [searchString, setSearchString] = useState();
const searchBanks = useCallback(
search => {
const filteredBanks = [];
banks.forEach(bank => {
if (bank[searchCategory].toLowerCase().includes(search.toLowerCase())) {
filteredBanks.push(bank);
}
});
setFilteredBanks(filteredBanks);
},
[banks, searchCategory, setFilteredBanks]
);
const debounceSearch = useCallback(() => _debounce(searchBanks, 500), [searchBanks]);
useEffect(() => {
if (searchString?.length) {
debounceSearch(searchString);
} else setFilteredBanks([]);
}, [searchString, searchCategory, setFilteredBanks, debounceSearch]);
const handleSearch = e => {
setSearchString(e.target.value);
};
return (
<div className="flex">
<Input placeholder="Bank Search" onChange={handleSearch} />
</div>
)}
It feels like the component should be a faily simple search and filter and it seems overly complicated for what it needs to do.
Again, I don't know the full context, however, I'd look into the compont architecture/structuring of the app and state.
I have used the multiselect-react-dropdown library for implement MultiSelect dropdown. I this library have two props: one is options and second is onSearch. In options props passed the data which is showing on dropdown. user can select the data from it. But onSearch is used on search user's by api. So how can i manage both the data on dropdown. Please help me to solve this.
<Multiselect
onSearch={(e) => callSearchApi(e)}
placeholder="Select Contributor"
onSelect={onSelect}
closeIcon="circle"
options={UsersData}
displayValue="name"
/>
API Call
const callSearchApi = (name) => {
console.log('callSearchApi', name);
collaboratorsSearch(name)
.then((response) => {
console.log('callSearchApi fetch user', response.data.results);
})
.catch((error) => {
console.log('callSearchApi fetch user Error', error);
});
};
To implement this feature you need to use react hook useState here.
You need to set below state and hooks. I am setting options inside useEffect here but in your case you can set it in button click event.
const [ selectedOptionValue, setSelectedOptionValue ] = useState('');
const [ options, setOptions ] = useState([]);
useEffect(() => {
setOptions(Data);
}, [])
You can toggle options data based on your requirement. Set this options variable either from callSearchApi or from props.
I have implemented onSearch and callSearchApi function here. Just to reduce the code I am using async and await.
const callSearchApi = async (value) => {
const response = await fetch('https://jsonplaceholder.typicode.com/users/?name='+ value);
const users = await response.json();
if (users && users.length > 0) {
setOptions(users);
} else {
setOptions(Data);
}
};
const onSearch = (value) => {
console.log('Value', value)
callSearchApi(value);
}
Please note I have used dummy API here which gives users information.
Here is the complete code.
import React, { useState, useEffect } from "react";
import "./style.css";
import Multiselect from 'multiselect-react-dropdown';
import { Data } from "./data";
export default function App() {
const [ selectedOptionValue, setSelectedOptionValue ] = useState('');
const [ options, setOptions ] = useState([]);
useEffect(async () => {
setOptions(Data);
}, [])
const onSelect = (selectedList, selectedItem) => {
}
const onRemove = (selectedList, removedItem) => {
}
const callSearchApi = async (value) => {
const response = await fetch('https://jsonplaceholder.typicode.com/users/?name='+ value);
const users = await response.json();
if (users && users.length > 0) {
setOptions(users);
} else {
setOptions(Data);
}
};
const onSearch = (value) => {
console.log('Value', value)
callSearchApi(value);
}
return (
<div>
<h1>Hello StackBlitz!</h1>
<p>Start editing to see some magic happen :)</p>
{
// options? options.length : null
options? ( <Multiselect
onSearch={onSearch}
options={options} // Options to display in the dropdown
selectedValues={selectedOptionValue} // Preselected value to persist in dropdown
onSelect={onSelect} // Function will trigger on select event
onRemove={onRemove} // Function will trigger on remove event
displayValue="name" // Property name to display in the dropdown options
/>): null
}
</div>
);
}
You can find working example here on stackblitz.
I have a React component using an infinite scroll to fetch information from an api using a pageToken.
When the user hits the bottom of the page, it should fetch the next bit of information. I thought myself clever for passing the pageToken to a useEffect hook, then updating it in the hook, but this is causing all of the api calls to run up front, thus defeating the use of the infinite scroll.
I think this might be related to React's derived state, but I am at a loss about how to solve this.
here is my component that renders the dogs:
export const Drawer = ({
onClose,
}: DrawerProps) => {
const [currentPageToken, setCurrentPageToken] = useState<
string | undefined | null
>(null);
const {
error,
isLoading,
data: allDogs,
nextPageToken,
} = useDogsList({
pageToken: currentPageToken,
});
const loader = useRef(null);
// When user scrolls to the end of the drawer, fetch more dogs
const handleObserver = useCallback(
(entries) => {
const [target] = entries;
if (target.isIntersecting) {
setCurrentPageToken(nextPageToken);
}
},
[nextPageToken],
);
useEffect(() => {
const option = {
root: null,
rootMargin: '20px',
threshold: 0,
};
const observer = new IntersectionObserver(handleObserver, option);
if (loader.current) observer.observe(loader.current);
}, [handleObserver]);
return (
<Drawer
onClose={onClose}
>
<List>
{allDogs?.map((dog) => (
<Fragment key={dog?.adopterAttributes?.id}>
<ListItem className={classes.listItem}>
{dog?.adopterAttributes?.id}
</ListItem>
</Fragment>
))}
{isLoading && <div>Loading...</div>}
<div ref={loader} />
</List>
</Drawer>
);
};
useDogsList essentially looks like this with all the cruft taken out:
import { useEffect, useRef, useState } from 'react';
export const useDogsList = ({
pageToken
}: useDogsListOptions) => {
const [isLoading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [nextPageToken, setNextPageToken] = useState<string | null | undefined>(
null,
);
const [allDogs, setAllDogs] = useState(null);
useEffect(() => {
const fetchData = async () => {
setLoading(true);
try {
const result =
await myClient.listDogs(
getDogsRequest,
{
token,
},
);
const dogListObject = result?.toObject();
const newDogs = result?.dogsList;
setNextPageToken(dogListObject?.pagination?.nextPageToken);
// if API returns a pageToken, that means there are more dogs to add to the list
if (nextPageToken) {
setAllDogs((previousDogList) => [
...(previousDogList ?? []),
...newDogs,
]);
}
}
} catch (responseError: unknown) {
if (responseError instanceof Error) {
setError(responseError);
} else {
throw responseError;
}
} finally {
setLoading(false);
}
};
fetchData();
}, [ pageToken, nextPageToken]);
return {
data: allDogs,
nextPageToken,
error,
isLoading,
};
};
Basically, the api call returns the nextPageToken, which I want to use for the next call when the user hits the intersecting point, but because nextPageToken is in the dependency array for the hook, the hook just keeps running. It retrieves all of the data until it compiles the whole list, without the user scrolling.
I'm wondering if I should be using useCallback or look more into derivedStateFromProps but I can't figure out how to make this a "controlled" component. Does anyone have any guidance here?
I suggest a small refactor of the useDogsList hook to instead return a hasNext flag and fetchNext callback.
export const useDogsList = ({ pageToken }: useDogsListOptions) => {
const [isLoading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [nextPageToken, setNextPageToken] = useState<string | null | undefined>(
pageToken // <-- initial token value for request
);
const [allDogs, setAllDogs] = useState([]);
// memoize fetchData callback for stable reference
const fetchData = useCallback(async () => {
setLoading(true);
try {
const result = await myClient.listDogs(getDogsRequest, { token: nextPageToken });
const dogListObject = result?.toObject();
const newDogs = result?.dogsList;
setNextPageToken(dogListObject?.pagination?.nextPageToken ?? null);
setAllDogs((previousDogList) => [...previousDogList, ...newDogs]);
} catch (responseError) {
if (responseError instanceof Error) {
setError(responseError);
} else {
throw responseError;
}
} finally {
setLoading(false);
}
}, [nextPageToken]);
useEffect(() => {
fetchData();
}, []); // call once on component mount
return {
data: allDogs,
hasNext: !!nextPageToken, // true if there is a next token
error,
isLoading,
fetchNext: fetchData, // callback to fetch next "page" of data
};
};
Usage:
export const Drawer = ({ onClose }: DrawerProps) => {
const { error, isLoading, data: allDogs, hasNext, fetchNext } = useDogsList({
pageToken // <-- pass initial page token
});
const loader = useRef(null);
// When user scrolls to the end of the drawer, fetch more dogs
const handleObserver = useCallback(
(entries) => {
const [target] = entries;
if (target.isIntersecting && hasNext) {
fetchNext(); // <-- Only fetch next if there is more to fetch
}
},
[hasNext, fetchNext]
);
useEffect(() => {
const option = {
root: null,
rootMargin: "20px",
threshold: 0
};
const observer = new IntersectionObserver(handleObserver, option);
if (loader.current) observer.observe(loader.current);
// From #stonerose036
// clear previous observer in returned useEffect cleanup function
return observer.disconnect;
}, [handleObserver]);
return (
<Drawer onClose={onClose}>
<List>
{allDogs?.map((dog) => (
<Fragment key={dog?.adopterAttributes?.id}>
<ListItem className={classes.listItem}>
{dog?.adopterAttributes?.id}
</ListItem>
</Fragment>
))}
{isLoading && <div>Loading...</div>}
<div ref={loader} />
</List>
</Drawer>
);
};
Disclaimer
Code hasn't been tested, but IMHO it should be pretty close to what you are after. There may be some minor tweaks necessary to satisfy any TSLinting issues, and getting the correct initial page token to the hook.
While Drew and #debuchet's answers helped me improve the code, the problem around multiple renders ended up being solved by tackling the observer itself. I had to disconnect it afterwards
useEffect(() => {
const option = {
root: null,
rootMargin: '20px',
threshold: 0,
};
const observer = new IntersectionObserver(handleObserver, option);
if (loader.current) observer.observe(loader.current);
return () => {
observer.disconnect();
};
}, [handleObserver]);