React Components: Difference between revisions
(48 intermediate revisions by the same user not shown) | |||
Line 18: | Line 18: | ||
</syntaxhighlight> | </syntaxhighlight> | ||
==Basic Page== | ==Basic Page== | ||
===Example Page=== | |||
In the demo the tutor builds an app which looks like this | In the demo the tutor builds an app which looks like this | ||
[[File:React components.png|900px]] | [[File:React components.png|900px]] | ||
<br> | <br> | ||
===Next JS Page=== | |||
We can create a next js page by returning JSX which represents the components we want on our page. Each line of the div can represent the components we may like to see on our page. E.g. Header, Menu, Search Bar, Speakers and Footer | |||
<syntaxhighlight lang="js"> | <syntaxhighlight lang="js"> | ||
function Page() { | function Page() { | ||
Line 37: | Line 39: | ||
</syntaxhighlight> | </syntaxhighlight> | ||
==Replacing with Components== | ===Replacing Next JS Page with Components=== | ||
===Introduction=== | |||
Here we have replaced the images with React components for the page. | |||
<syntaxhighlight lang="js"> | <syntaxhighlight lang="js"> | ||
function Page() { | function Page() { | ||
Line 59: | Line 56: | ||
export default Page | export default Page | ||
</syntaxhighlight> | </syntaxhighlight> | ||
== | |||
===Header Component=== | |||
Here is the example Header Component | |||
<syntaxhighlight lang="js"> | |||
import React from 'react' | |||
const Header = () => <img src="images/header.png" /> | |||
export default Header | |||
</syntaxhighlight> | |||
===Speaker Component=== | |||
This component combines the fixed data with the rendering in one component. A good start for a prototype but lets look at abstracting the component below. | |||
<syntaxhighlight lang="js"> | <syntaxhighlight lang="js"> | ||
const Speakers = () => { | const Speakers = () => { | ||
Line 89: | Line 95: | ||
* Context | * Context | ||
Context in React is used to share data that is global to a component tree such as an authenticated user or preferred theme without passing that data as props. | Context in React is used to share data that is global to a component tree such as an authenticated user or preferred theme without passing that data as props. | ||
==HOC== | ==Higher-Order Component HOC== | ||
===Simple Example of HOC=== | ===Simple Example of HOC=== | ||
As specified above a | As specified above a HOC is a function that takes a component and returns a new component | ||
<syntaxhighlight lang="js"> | <syntaxhighlight lang="js"> | ||
const EnhancedSpeakerComponent = withData(Speakers) | const EnhancedSpeakerComponent = withData(Speakers) | ||
Line 199: | Line 205: | ||
} | } | ||
</syntaxhighlight> | </syntaxhighlight> | ||
==Render Props== | ==Render Props== | ||
===Boilerplate Code=== | ===Boilerplate Code=== | ||
Line 409: | Line 416: | ||
} | } | ||
export default SpeakerImage; | export default SpeakerImage; | ||
</syntaxhighlight> | |||
<br> | |||
@jmperezperez provided a component to detect if visible | |||
<syntaxhighlight lang="js"> | |||
class Observer extends Component { | |||
constructor() { | |||
super(); | |||
this.state = { isVisible: false }; | |||
this.io = null; | |||
this.container = null; | |||
} | |||
componentDidMount() { | |||
this.io = new IntersectionObserver([entry] => { | |||
this.setState({ isVisible: entry.isIntersecting }); | |||
}, {}); | |||
this.io.observe(this.container); | |||
} | |||
componentWillUnmount() { | |||
if (this.io) { | |||
this.io.disconnect(); | |||
} | |||
} | |||
render() { | |||
return ( | |||
// we create a div to get a reference. | |||
// It's possible to use findDOMNode() to avoid | |||
// creating extra elements, but findDOMNode is discouraged | |||
<div | |||
ref={div => { | |||
this.container = div; | |||
}} | |||
> | |||
{Array.isArray(this.props.children) | |||
? this.props.children.map(child => child(this.state.isVisible)) | |||
: this.props.children(this.state.isVisible)} | |||
</div> | |||
); | |||
} | |||
} | |||
</syntaxhighlight> | |||
==Implementing Filtering== | |||
To add filtering we use the useState hook in the Speakers page. We passed the value and the setter to the SpeakerSearchBar. | |||
<syntaxhighlight lang="js"> | |||
const SpeakerSearchBar = ({ searchQuery, setSearchQuery }) => ( | |||
<div className="mb-6"> | |||
<input | |||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" | |||
id="username" | |||
type="text" | |||
placeholder="Search by name" | |||
value={searchQuery} | |||
onChange={(e) => setSearchQuery(e.target.value)} | |||
/> | |||
</div> | |||
); | |||
export default SpeakerSearchBar; | |||
</syntaxhighlight> | |||
And now add the filtering changing | |||
<syntaxhighlight lang="js"> | |||
<div className="grid md:grid-cols-2 lg:grid-cols-3 grid-cols-1 gap-12"> | |||
{speakers | |||
.map((speaker) => ( | |||
<Speaker key={speaker.id} {...speaker} /> | |||
))} | |||
</div> | |||
</syntaxhighlight> | |||
<br> | |||
To add the filtering | |||
<syntaxhighlight lang="js"> | |||
<div className="grid md:grid-cols-2 lg:grid-cols-3 grid-cols-1 gap-12"> | |||
{speakers | |||
.filter((rec) => { | |||
const targetString = `${rec.firstName} ${rec.lastName}`.toLowerCase(); | |||
return searchQuery.length === 0 | |||
? true | |||
: targetString.includes(searchQuery.toLowerCase()); | |||
}) | |||
.map((speaker) => ( | |||
<Speaker key={speaker.id} {...speaker} /> | |||
))} | |||
</div> | |||
</syntaxhighlight> | |||
==Using State to update== | |||
This is a problem I encountered with the play state of a track. There is the original data (tracks) and the current state which could be playing, stopped, paused, neither. For the speaker app we are toggling a favourite button against a speaker and the solution was to | |||
*Put the data into a state | |||
*Merge the data when changed (onFavoriteToggleHandler) | |||
*Pass the handler to the component | |||
<syntaxhighlight lang="js"> | |||
// Splits the record and toggles the isFavourite | |||
function toggleSpeakerFavorite(speakerRec) { | |||
return { | |||
...speakerRec, | |||
isFavorite: !speakerRec.isFavorite, | |||
}; | |||
} | |||
// Replaces the record in the state for this record | |||
// Like the use of slice for replacing | |||
function onFavoriteToggleHandler(speakerRec) { | |||
const toggledSpeakerRec = toggleSpeakerFavorite(speakerRec) | |||
const speakerIndex = speakers | |||
.map((speaker) => speaker.id) | |||
.indexOf(speakerRec.id); | |||
setSpeakers([ | |||
...speakers.slice(0, speakerIndex), | |||
toggledSpeakerRec, | |||
...speakers.slice(speakerIndex + 1), | |||
]); | |||
} | |||
</syntaxhighlight> | |||
==Implementing REST== | |||
Not many surprises we are going to be using axios for this. | |||
===Add GET Request=== | |||
Using the useEffect we create a function to fetch the data | |||
<syntaxhighlight lang="js"> | |||
useEffect(() => { | |||
const fetchData = async () => { | |||
try { | |||
const response = await axios.get('http://localhost:4000/speakers'); | |||
setSpeakers(response.data); | |||
setStatus(REQUEST_STATUS.SUCCESS); | |||
} catch (e) { | |||
setStatus(REQUEST_STATUS.ERROR); | |||
setError(e); | |||
} | |||
}; | |||
fetchData(); | |||
}, []); | |||
</syntaxhighlight> | |||
===Add PUT Request=== | |||
We change the previous useState to implement a PUT request | |||
<syntaxhighlight lang="js"> | |||
async function onFavoriteToggleHandler(speakerRec) { | |||
const toggledSpeakerRec = toggleSpeakerFavorite(speakerRec); | |||
const speakerIndex = speakers | |||
.map((speaker) => speaker.id) | |||
.indexOf(speakerRec.id); | |||
try { | |||
await axios.put( | |||
`http://localhost:4000/speakers/${speakerRec.id}`, | |||
toggledSpeakerRec, | |||
); | |||
setSpeakers([ | |||
...speakers.slice(0, speakerIndex), | |||
toggledSpeakerRec, | |||
...speakers.slice(speakerIndex + 1), | |||
]); | |||
} catch (e) { | |||
setStatus(REQUEST_STATUS.ERROR); | |||
setError(e); | |||
} | |||
} | |||
</syntaxhighlight> | |||
===Add Json server to the project=== | |||
Add the package json-server and add an additional script | |||
<syntaxhighlight lang="json"> | |||
"json-server": "json-server --watch db.json --port 4000 --delay 1000" | |||
</syntaxhighlight> | |||
<br> | |||
Create some data | |||
<syntaxhighlight lang="json"> | |||
{ | |||
"speakers": [ | |||
{ | |||
"id": 1530, | |||
"firstName": "Tamara", | |||
"lastName": "Baker", | |||
"sat": false, | |||
"sun": true, | |||
"isFavorite": false, | |||
"bio": "Tammy has held a number of executive and management roles over the past 15 years, including VP engineering Roles at Molekule Inc., Cantaloupe Systems, E-Color, and Untangle Inc." | |||
}, | |||
{ | |||
"id": 5996, | |||
"firstName": "Craig", | |||
"lastName": "Berntson", | |||
.... | |||
</syntaxhighlight> | |||
===Showing Status=== | |||
In Angular we used templates and *ngIf and names for react we do the following | |||
<syntaxhighlight lang="js"> | |||
... | |||
{isLoading && <div>Loading...</div>} | |||
{hasErrored && ( | |||
<div> | |||
Loading error... Is the json-server running? (try "npm run | |||
json-server" at terminal prompt) | |||
<br /> | |||
<b>ERROR: {error.message}</b> | |||
</div> | |||
)} | |||
{success && ( | |||
<div className="grid md:grid-cols-2 lg:grid-cols-3 grid-cols-1 gap-12"> | |||
{speakers | |||
... | |||
</syntaxhighlight> | |||
==Implementing Reducers== | |||
In the code we are managing three lots of state. This is a good case for using a reducer (redux). | |||
<br> | |||
Before Code | |||
<syntaxhighlight lang="js"> | |||
import React, { useState, useEffect } from 'react'; | |||
import axios from 'axios'; | |||
import SpeakerSearchBar from '../SpeakerSearchBar/SpeakerSearchBar'; | |||
import Speaker from '../Speaker/Speaker'; | |||
const Speakers = () => { | |||
function toggleSpeakerFavorite(speakerRec) { | |||
return { | |||
...speakerRec, | |||
isFavorite: !speakerRec.isFavorite, | |||
}; | |||
} | |||
async function onFavoriteToggleHandler(speakerRec) { | |||
const toggledSpeakerRec = toggleSpeakerFavorite(speakerRec); | |||
const speakerIndex = speakers | |||
.map((speaker) => speaker.id) | |||
.indexOf(speakerRec.id); | |||
try { | |||
await axios.put( | |||
`http://localhost:4000/speakers/${speakerRec.id}`, | |||
toggledSpeakerRec, | |||
); | |||
setSpeakers([ | |||
...speakers.slice(0, speakerIndex), | |||
toggledSpeakerRec, | |||
...speakers.slice(speakerIndex + 1), | |||
]); | |||
} catch (e) { | |||
setStatus(REQUEST_STATUS.ERROR); | |||
setError(e); | |||
} | |||
} | |||
const [searchQuery, setSearchQuery] = useState(''); | |||
const [speakers, setSpeakers] = useState([]); | |||
const REQUEST_STATUS = { | |||
LOADING: 'loading', | |||
SUCCESS: 'success', | |||
ERROR: 'error', | |||
}; | |||
const [status, setStatus] = useState(REQUEST_STATUS.LOADING); | |||
const [error, setError] = useState({}); | |||
useEffect(() => { | |||
const fetchData = async () => { | |||
try { | |||
const response = await axios.get('http://localhost:4000/speakers/'); | |||
setSpeakers(response.data); | |||
setStatus(REQUEST_STATUS.SUCCESS); | |||
} catch (e) { | |||
console.log('Loading data error', e); | |||
setStatus(REQUEST_STATUS.ERROR); | |||
setError(e); | |||
} | |||
}; | |||
fetchData(); | |||
}, []); | |||
const success = status === REQUEST_STATUS.SUCCESS; | |||
const isLoading = status === REQUEST_STATUS.LOADING; | |||
const hasErrored = status === REQUEST_STATUS.ERROR; | |||
return ( | |||
<div> | |||
<SpeakerSearchBar | |||
searchQuery={searchQuery} | |||
setSearchQuery={setSearchQuery} | |||
/> | |||
{isLoading && <div>Loading...</div>} | |||
{hasErrored && ( | |||
<div> | |||
Loading error... Is the json-server running? (try "npm run | |||
json-server" at terminal prompt) | |||
<br /> | |||
<b>ERROR: {error.message}</b> | |||
</div> | |||
)} | |||
{success && ( | |||
<div className="grid md:grid-cols-2 lg:grid-cols-3 grid-cols-1 gap-12"> | |||
{speakers | |||
.filter((rec) => { | |||
const targetString = `${rec.firstName} ${rec.lastName}`.toLowerCase(); | |||
return searchQuery.length === 0 | |||
? true | |||
: targetString.includes(searchQuery.toLowerCase()); | |||
}) | |||
.map((speaker) => ( | |||
<Speaker | |||
key={speaker.id} | |||
{...speaker} | |||
onFavoriteToggle={() => onFavoriteToggleHandler(speaker)} | |||
/> | |||
))} | |||
</div> | |||
)} | |||
</div> | |||
); | |||
}; | |||
export default Speakers; | |||
</syntaxhighlight> | |||
<br> | |||
After code | |||
<syntaxhighlight lang="js"> | |||
import React, { useState, useEffect, useReducer } from 'react'; | |||
import axios from 'axios'; | |||
import SpeakerSearchBar from '../SpeakerSearchBar/SpeakerSearchBar'; | |||
import Speaker from '../Speaker/Speaker'; | |||
const Speakers = () => { | |||
function toggleSpeakerFavorite(speakerRec) { | |||
return { | |||
...speakerRec, | |||
isFavorite: !speakerRec.isFavorite, | |||
}; | |||
} | |||
async function onFavoriteToggleHandler(speakerRec) { | |||
const toggledSpeakerRec = toggleSpeakerFavorite(speakerRec); | |||
const speakerIndex = speakers | |||
.map((speaker) => speaker.id) | |||
.indexOf(speakerRec.id); | |||
try { | |||
await axios.put( | |||
`http://localhost:4000/speakers/${speakerRec.id}`, | |||
toggledSpeakerRec, | |||
); | |||
setSpeakers([ | |||
...speakers.slice(0, speakerIndex), | |||
toggledSpeakerRec, | |||
...speakers.slice(speakerIndex + 1), | |||
]); | |||
} catch (e) { | |||
setStatus(REQUEST_STATUS.ERROR); | |||
setError(e); | |||
} | |||
} | |||
const [searchQuery, setSearchQuery] = useState(''); | |||
const REQUEST_STATUS = { | |||
LOADING: 'loading', | |||
SUCCESS: 'success', | |||
ERROR: 'error', | |||
}; | |||
const reducer = (state, action) => { | |||
switch (action.type) { | |||
case 'GET_ALL_SUCCESS': | |||
return { | |||
...state, | |||
status: REQUEST_STATUS.SUCCESS, | |||
speakers: action.speakers, | |||
}; | |||
case 'UPDATE_STATUS': | |||
return { | |||
...state, | |||
status: action.status, | |||
}; | |||
} | |||
}; | |||
const [{ speakers, status }, dispatch] = useReducer(reducer, { | |||
status: REQUEST_STATUS.LOADING, | |||
speakers: [], | |||
}); | |||
const [error, setError] = useState({}); | |||
useEffect(() => { | |||
const fetchData = async () => { | |||
try { | |||
const response = await axios.get('http://localhost:4000/speakers/'); | |||
dispatch({ | |||
speakers: response.data, | |||
type: 'GET_ALL_SUCCESS', | |||
}); | |||
} catch (e) { | |||
console.log('Loading data error', e); | |||
dispatch({ | |||
status: REQUEST_STATUS.ERROR, | |||
type: 'UPDATE_STATUS', | |||
}); | |||
setError(e); | |||
} | |||
}; | |||
fetchData(); | |||
}, []); | |||
const success = status === REQUEST_STATUS.SUCCESS; | |||
const isLoading = status === REQUEST_STATUS.LOADING; | |||
const hasErrored = status === REQUEST_STATUS.ERROR; | |||
return ( | |||
<div> | |||
<SpeakerSearchBar | |||
searchQuery={searchQuery} | |||
setSearchQuery={setSearchQuery} | |||
/> | |||
{isLoading && <div>Loading...</div>} | |||
{hasErrored && ( | |||
<div> | |||
Loading error... Is the json-server running? (try "npm run | |||
json-server" at terminal prompt) | |||
<br /> | |||
<b>ERROR: {error.message}</b> | |||
</div> | |||
)} | |||
{success && ( | |||
<div className="grid md:grid-cols-2 lg:grid-cols-3 grid-cols-1 gap-12"> | |||
{speakers | |||
.filter((rec) => { | |||
const targetString = `${rec.firstName} ${rec.lastName}`.toLowerCase(); | |||
return searchQuery.length === 0 | |||
? true | |||
: targetString.includes(searchQuery.toLowerCase()); | |||
}) | |||
.map((speaker) => ( | |||
<Speaker | |||
key={speaker.id} | |||
{...speaker} | |||
onFavoriteToggle={() => onFavoriteToggleHandler(speaker)} | |||
/> | |||
))} | |||
</div> | |||
)} | |||
</div> | |||
); | |||
}; | |||
export default Speakers; | |||
</syntaxhighlight> | |||
==Final Reducer Code== | |||
This shows the final changes. Whilst we will never use the code it does show the approach to take if we want to start using reducers. | |||
===Add consts to replace string literals=== | |||
Add actions/request.js | |||
<syntaxhighlight lang="js"> | |||
export const GET_ALL_SUCCESS = 'GET_ALL_SUCCESS'; | |||
export const GET_ALL_FAILURE = 'GET_ALL_FAILURE'; | |||
export const PUT_SUCCESS = 'PUT_SUCCESS'; | |||
export const PUT_FAILURE = 'PUT_FAILURE'; | |||
</syntaxhighlight> | |||
===Externalize Reducer=== | |||
Move reducer code to reducers/request.js | |||
<syntaxhighlight lang="js"> | |||
import { | |||
GET_ALL_SUCCESS, | |||
GET_ALL_FAILURE, | |||
PUT_SUCCESS, | |||
PUT_FAILURE, | |||
} from '../actions/request'; | |||
export const REQUEST_STATUS = { | |||
LOADING: 'loading', | |||
SUCCESS: 'success', | |||
ERROR: 'error', | |||
}; | |||
const requestReducer = (state, action) => { | |||
switch (action.type) { | |||
case GET_ALL_SUCCESS: { | |||
return { | |||
...state, | |||
records: action.records, | |||
status: REQUEST_STATUS.SUCCESS, | |||
}; | |||
} | |||
case GET_ALL_FAILURE: { | |||
return { | |||
...state, | |||
status: REQUEST_STATUS.ERROR, | |||
error: action.error, | |||
}; | |||
} | |||
case PUT_SUCCESS: | |||
const { records } = state; | |||
const { record } = action; | |||
const recordIndex = records.map((rec) => rec.id).indexOf(record.id); | |||
return { | |||
...state, | |||
records: [ | |||
...records.slice(0, recordIndex), | |||
record, | |||
...records.slice(recordIndex + 1), | |||
], | |||
}; | |||
case PUT_FAILURE: | |||
console.log( | |||
'PUT_FAILURE: Currently just logging to console without refreshing records list', | |||
); | |||
return { | |||
...state, | |||
error: action.error, | |||
}; | |||
default: | |||
return state; | |||
} | |||
}; | |||
export default requestReducer; | |||
</syntaxhighlight> | |||
===Remove dead code from Speakers=== | |||
Remove the dead code and add call to useReducer | |||
<syntaxhighlight lang="js"> | |||
import React, { useState, useEffect, useReducer } from 'react'; | |||
import axios from 'axios'; | |||
import SpeakerSearchBar from '../SpeakerSearchBar/SpeakerSearchBar'; | |||
import Speaker from '../Speaker/Speaker'; | |||
import requestReducer from '../../reducers/request'; | |||
import { | |||
GET_ALL_FAILURE, | |||
GET_ALL_SUCCESS, | |||
PUT_FAILURE, | |||
PUT_SUCCESS, | |||
} from '../../actions/request'; | |||
const REQUEST_STATUS = { | |||
LOADING: 'loading', | |||
SUCCESS: 'success', | |||
ERROR: 'error', | |||
}; | |||
const Speakers = () => { | |||
const onFavoriteToggleHandler = async (speakerRec) => { | |||
try { | |||
const toggledSpeakerRec = { | |||
...speakerRec, | |||
isFavorite: !speakerRec.isFavorite, | |||
}; | |||
await axios.put( | |||
`http://localhost:4000/speakers/${speakerRec.id}`, | |||
toggledSpeakerRec, | |||
); | |||
dispatch({ | |||
type: PUT_SUCCESS, | |||
record: toggledSpeakerRec, | |||
}); | |||
} catch (e) { | |||
dispatch({ | |||
type: PUT_FAILURE, | |||
error: e, | |||
}); | |||
} | |||
}; | |||
const [searchQuery, setSearchQuery] = useState(''); | |||
const [{ records: speakers, status, error }, dispatch] = useReducer( | |||
requestReducer, | |||
{ | |||
records: [], | |||
status: REQUEST_STATUS.LOADING, | |||
error: null, | |||
}, | |||
); | |||
useEffect(() => { | |||
const fetchData = async () => { | |||
try { | |||
const response = await axios.get('http://localhost:4000/speakers/'); | |||
dispatch({ | |||
type: GET_ALL_SUCCESS, | |||
records: response.data, | |||
}); | |||
} catch (e) { | |||
console.log('Loading data error', e); | |||
dispatch({ | |||
type: GET_ALL_FAILURE, | |||
error: e, | |||
}); | |||
} | |||
}; | |||
fetchData(); | |||
}, []); | |||
const success = status === REQUEST_STATUS.SUCCESS; | |||
const isLoading = status === REQUEST_STATUS.LOADING; | |||
const hasErrored = status === REQUEST_STATUS.ERROR; | |||
return ( | |||
<div> | |||
<SpeakerSearchBar | |||
searchQuery={searchQuery} | |||
setSearchQuery={setSearchQuery} | |||
/> | |||
{isLoading && <div>Loading...</div>} | |||
{hasErrored && ( | |||
<div> | |||
Loading error... Is the json-server running? (try "npm run | |||
json-server" at terminal prompt) | |||
<br /> | |||
<b>ERROR: {error.message}</b> | |||
</div> | |||
)} | |||
{success && ( | |||
<div className="grid md:grid-cols-2 lg:grid-cols-3 grid-cols-1 gap-12"> | |||
{speakers | |||
.filter((rec) => { | |||
const targetString = `${rec.firstName} ${rec.lastName}`.toLowerCase(); | |||
return searchQuery.length === 0 | |||
? true | |||
: targetString.includes(searchQuery.toLowerCase()); | |||
}) | |||
.map((speaker) => ( | |||
<Speaker | |||
key={speaker.id} | |||
{...speaker} | |||
onFavoriteToggle={() => onFavoriteToggleHandler(speaker)} | |||
/> | |||
))} | |||
</div> | |||
)} | |||
</div> | |||
); | |||
}; | |||
export default Speakers; | |||
</syntaxhighlight> | |||
=More HOC and Render Props= | |||
==Approach== | |||
The way to approach creating an HOC is to look at what the rendering component needs in terms of props. In the case of Speakers this was Speakers, Status and Error. Also the Speakers are updated on whether they are favourite or not so adding a function as a parameter will be a way to remove this from the rendering component. | |||
<br> | |||
So our HOC will now look like this, the baseURL and routeName are passed from the rendering component (possibly needs to be somewhere else), but the main takeaway is that you look at the outputs and frame your return function around it. | |||
<br> | |||
'''Warning Dont use this''' | |||
<syntaxhighlight lang="js"> | |||
const withRequest = (baseUrl, routeName) => (Component) => () => { | |||
const props = { | |||
records, // Speakers | |||
status, | |||
error, | |||
put, // Function to update speaker | |||
} | |||
return <Component {...props}></Component> | |||
} | |||
export default withRequest | |||
</syntaxhighlight> | |||
<br> | |||
This above approach works but is does not consider that the component being passed may have props. It is did they would be lost. Instead, add the props to the incoming and outgoing so they are not lost and rename the hoc props to avoid collisions | |||
<syntaxhighlight lang="js"> | |||
const withRequest = (baseUrl, routeName) => (Component) => (props) => { | |||
const localProps = { | |||
records, // Speakers | |||
status, | |||
error, | |||
put, // Function to update speaker | |||
} | |||
return <Component {...props}{...localProps}></Component> | |||
} | |||
export default withRequest | |||
</syntaxhighlight> | |||
==Multiple HOCs== | |||
We may need to have multiple HOC for a component. To implement this you could chain the calls together | |||
<syntaxhighlight lang="js"> | |||
export default withSpecialMessage( | |||
withRequest('http://localhost:4000', 'speakers')(Speakers)) | |||
</syntaxhighlight> | |||
<br> | |||
But this could get messy so you can use the npm package recompose. Then you can do. Add the import and you can pass them like this | |||
<syntaxhighlight lang="js"> | |||
export default compose( | |||
withRequest('http://localhost:4000', 'speakers'), | |||
withSpecialMessage() | |||
)(Speakers) | |||
</syntaxhighlight> | |||
<br> | |||
==Final HOC Speaker Component== | |||
Lets look at the finished component with two HOCs | |||
<syntaxhighlight lang="js"> | |||
import React, { useState } from 'react'; | |||
import SpeakerSearchBar from '../SpeakerSearchBar/SpeakerSearchBar'; | |||
import Speaker from '../Speaker/Speaker'; | |||
import { REQUEST_STATUS } from '../../reducers/request'; | |||
import withRequest from '../HOCs/withRequest'; | |||
import withSpecialMessage from '../HOCs/withSpecialMessage'; | |||
import { compose } from 'recompose'; | |||
const Speakers = ({ | |||
records: speakers, | |||
status, | |||
error, | |||
put, | |||
bgColor, | |||
specialMessage, | |||
}) => { | |||
const onFavoriteToggleHandler = async (speakerRec) => { | |||
put({ | |||
...speakerRec, | |||
isFavorite: !speakerRec.isFavorite, | |||
}); | |||
}; | |||
const [searchQuery, setSearchQuery] = useState(''); | |||
const success = status === REQUEST_STATUS.SUCCESS; | |||
const isLoading = status === REQUEST_STATUS.LOADING; | |||
const hasErrored = status === REQUEST_STATUS.ERROR; | |||
return ( | |||
<div className={bgColor}> | |||
<SpeakerSearchBar | |||
searchQuery={searchQuery} | |||
setSearchQuery={setSearchQuery} | |||
/> | |||
{specialMessage && specialMessage.length > 0 && ( | |||
<div | |||
className="bg-orange-100 border-l-8 border-orange-500 text-orange-700 p-4 text-2xl" | |||
role="alert" | |||
> | |||
<p className="font-bold">Special Message</p> | |||
<p>{specialMessage}</p> | |||
</div> | |||
)} | |||
{isLoading && <div>Loading...</div>} | |||
{hasErrored && ( | |||
<div> | |||
Loading error... Is the json-server running? (try "npm run | |||
json-server" at terminal prompt) | |||
<br /> | |||
<b>ERROR: {error.message}</b> | |||
</div> | |||
)} | |||
{success && ( | |||
<div className="grid md:grid-cols-2 lg:grid-cols-3 grid-cols-1 gap-12"> | |||
{speakers | |||
.filter((rec) => { | |||
const targetString = `${rec.firstName} ${rec.lastName}`.toLowerCase(); | |||
return searchQuery.length === 0 | |||
? true | |||
: targetString.includes(searchQuery.toLowerCase()); | |||
}) | |||
.map((speaker) => ( | |||
<Speaker | |||
key={speaker.id} | |||
{...speaker} | |||
onFavoriteToggle={() => onFavoriteToggleHandler(speaker)} | |||
/> | |||
))} | |||
</div> | |||
)} | |||
</div> | |||
); | |||
}; | |||
export default compose( | |||
withRequest('http://localhost:4000', 'speakers'), | |||
withSpecialMessage(), | |||
)(Speakers); | |||
</syntaxhighlight> | |||
==Render Props== | |||
Below is the equivalent code using render props. I think it is far more cluttered than the HOC Approach. Firstly the RP functions then then final Speaker component | |||
===SpecialMessageRenderProps=== | |||
For create the special message | |||
<syntaxhighlight lang="js"> | |||
function SpecialMessageRenderProps({ children }) { | |||
return children({ | |||
// could get this from something like a push notification. | |||
specialMessage: 'Angular Class at 2:45PM Cancelled', | |||
}); | |||
} | |||
export default SpecialMessageRenderProps; | |||
</syntaxhighlight> | |||
===Request=== | |||
For performing the request | |||
<syntaxhighlight lang="js"> | |||
import React, { useEffect, useReducer } from 'react'; | |||
import axios from 'axios'; | |||
import requestReducer, { REQUEST_STATUS } from '../../reducers/request'; | |||
import { | |||
PUT_SUCCESS, | |||
PUT_FAILURE, | |||
GET_ALL_SUCCESS, | |||
GET_ALL_FAILURE, | |||
} from '../../actions/request'; | |||
const Request = ({ baseUrl, routeName, children }) => { | |||
const [{ records, status, error }, dispatch] = useReducer(requestReducer, { | |||
status: REQUEST_STATUS.LOADING, | |||
records: [], | |||
error: null, | |||
}); | |||
useEffect(() => { | |||
const fetchData = async () => { | |||
try { | |||
const response = await axios.get(`${baseUrl}/${routeName}`); | |||
dispatch({ | |||
type: GET_ALL_SUCCESS, | |||
records: response.data, | |||
}); | |||
} catch (e) { | |||
console.log('Loading data error', e); | |||
dispatch({ | |||
type: GET_ALL_FAILURE, | |||
error: e, | |||
}); | |||
} | |||
}; | |||
fetchData(); | |||
}, [baseUrl, routeName]); | |||
const childProps = { | |||
records, | |||
status, | |||
error, | |||
put: async (record) => { | |||
dispatch({ | |||
type: 'PUT', | |||
}); | |||
try { | |||
await axios.put(`${baseUrl}/${routeName}/${record.id}`, record); | |||
dispatch({ | |||
type: PUT_SUCCESS, | |||
record, | |||
}); | |||
} catch (e) { | |||
dispatch({ | |||
type: PUT_FAILURE, | |||
error: e, | |||
}); | |||
} | |||
}, | |||
}; | |||
return children(childProps); | |||
}; | |||
export default Request; | |||
</syntaxhighlight> | |||
===Final Speaker Component in RP=== | |||
<syntaxhighlight lang="js"> | |||
import React, { useState } from 'react'; | |||
import SpeakerSearchBar from '../SpeakerSearchBar/SpeakerSearchBar'; | |||
import Speaker from '../Speaker/Speaker'; | |||
import { REQUEST_STATUS } from '../../reducers/request'; | |||
import Request from '../RPs/Request'; | |||
import SpecialMessageRenderProps from '../RPs/SpecialMessageRenderProps'; | |||
const Speakers = ({ bgColor }) => { | |||
const [searchQuery, setSearchQuery] = useState(''); | |||
return ( | |||
<div className={bgColor}> | |||
<SpeakerSearchBar | |||
searchQuery={searchQuery} | |||
setSearchQuery={setSearchQuery} | |||
/> | |||
<> | |||
<SpecialMessageRenderProps> | |||
{({ specialMessage }) => { | |||
return ( | |||
<Request baseUrl="http://localhost:4000" routeName="speakers"> | |||
{({ records: speakers, status, error, put }) => { | |||
const onFavoriteToggleHandler = async (speakerRec) => { | |||
put({ | |||
...speakerRec, | |||
isFavorite: !speakerRec.isFavorite, | |||
}); | |||
}; | |||
const success = status === REQUEST_STATUS.SUCCESS; | |||
const isLoading = status === REQUEST_STATUS.LOADING; | |||
const hasErrored = status === REQUEST_STATUS.ERROR; | |||
return ( | |||
<> | |||
{specialMessage && specialMessage.length > 0 && ( | |||
<div | |||
className="bg-orange-100 border-l-8 border-orange-500 text-orange-700 p-4 text-2xl" | |||
role="alert" | |||
> | |||
<p className="font-bold">Special Message</p> | |||
<p>{specialMessage}</p> | |||
</div> | |||
)} | |||
{isLoading && <div>Loading...</div>} | |||
{hasErrored && ( | |||
<div> | |||
Loading error... Is the json-server running? (try "npm | |||
run json-server" at terminal prompt) | |||
<br /> | |||
<b>ERROR: {error.message}</b> | |||
</div> | |||
)} | |||
{success && ( | |||
<div className="grid md:grid-cols-2 lg:grid-cols-3 grid-cols-1 gap-12"> | |||
{speakers | |||
.filter((rec) => { | |||
const targetString = `${rec.firstName} ${rec.lastName}`.toLowerCase(); | |||
return searchQuery.length === 0 | |||
? true | |||
: targetString.includes( | |||
searchQuery.toLowerCase(), | |||
); | |||
}) | |||
.map((speaker) => ( | |||
<Speaker | |||
key={speaker.id} | |||
{...speaker} | |||
onFavoriteToggle={() => | |||
onFavoriteToggleHandler(speaker) | |||
} | |||
/> | |||
))} | |||
</div> | |||
)} | |||
</> | |||
); | |||
}} | |||
</Request> | |||
); | |||
}} | |||
</SpecialMessageRenderProps> | |||
</> | |||
</div> | |||
); | |||
}; | |||
export default Speakers; | |||
</syntaxhighlight> | |||
=More Context= | |||
==Orginal Context== | |||
We need to create a more encapsulated context. | |||
Previously | |||
<syntaxhighlight lang="js"> | |||
import React from 'react'; | |||
const SpeakerContext = React.createContext({}); | |||
export default SpeakerContext; | |||
</syntaxhighlight> | |||
<br> | |||
And Wrapping the components with the context | |||
<syntaxhighlight lang="js"> | |||
export default function Page() { | |||
const speakers = [ | |||
{ | |||
imageSrc: 'speaker-component-1124', | |||
name: 'Douglas Crockford', | |||
}, | |||
{ | |||
imageSrc: 'speaker-component-1530', | |||
name: 'Tamara Baker', | |||
}, | |||
{ | |||
imageSrc: 'speaker-component-10803', | |||
name: 'Eugene Chuvyrov', | |||
}, | |||
]; | |||
return ( | |||
<div> | |||
<Header /> | |||
<Menu /> | |||
<SpeakerContext.Provider value={speakers}> | |||
<SpeakerSearchBar /> | |||
<Speakers /> | |||
</SpeakerContext.Provider> | |||
<Footer /> | |||
</div> | |||
); | |||
} | |||
</syntaxhighlight> | |||
==Creating a Better Context== | |||
This can now be expanded to encapsulate the provider and to manage it's own data | |||
<syntaxhighlight lang="js"> | |||
import React, { createContext } from 'react'; | |||
const SpeakersContext = createContext(); | |||
const SpeakersProvider = ({ children }) => { | |||
const speakers = [ | |||
{ imageSrc: 'speaker-component-1124', name: 'Douglas Crockford' }, | |||
{ imageSrc: 'speaker-component-1530', name: 'Tamara Baker' }, | |||
{ imageSrc: 'speaker-component-10803', name: 'Eugene Chuvyrov' }, | |||
]; | |||
return ( | |||
<SpeakersContext.Provider value={speakers}> | |||
{children} | |||
</SpeakersContext.Provider> | |||
); | |||
}; | |||
export { SpeakersContext, SpeakersProvider }; | |||
</syntaxhighlight> | |||
<br> | |||
Below is the reworked Speakers component | |||
<syntaxhighlight lang="js"> | |||
const Speakers = () => { | |||
return ( | |||
<SpeakersProvider> | |||
<SpeakersComponent></SpeakersComponent> | |||
</SpeakersProvider> | |||
); | |||
}; | |||
export default Speakers; | |||
</syntaxhighlight> | |||
==Implementing into Speaker Component== | |||
We now implement the use of the context into the Speaker context built with the HOC. The context been renamed to DataContext and DataProvider but the rendering code is much the same. The main change is to the export which wraps the component in a context ensuring that any child props are not lost | |||
<syntaxhighlight lang="js"> | |||
import React, { useState, useContext } from 'react'; | |||
import { DataContext, DataProvider } from '../../contexts/DataContext'; | |||
import SpeakerSearchBar from '../SpeakerSearchBar/SpeakerSearchBar'; | |||
import Speaker from '../Speaker/Speaker'; | |||
import { REQUEST_STATUS } from '../../reducers/request'; | |||
import withRequest from '../HOCs/withRequest'; | |||
import withSpecialMessage from '../HOCs/withSpecialMessage'; | |||
import { compose } from 'recompose'; | |||
const SpeakersComponent = ({ bgColor }) => { | |||
const specialMessage = ''; | |||
const { speakers, status } = useContext(DataContext); | |||
const onFavoriteToggleHandler = async (speakerRec) => { | |||
put({ | |||
...speakerRec, | |||
isFavorite: !speakerRec.isFavorite, | |||
}); | |||
}; | |||
const [searchQuery, setSearchQuery] = useState(''); | |||
const success = status === REQUEST_STATUS.SUCCESS; | |||
const isLoading = status === REQUEST_STATUS.LOADING; | |||
const hasErrored = status === REQUEST_STATUS.ERROR; | |||
// Rendering code unchanged | |||
return ( | |||
<div className={bgColor}> | |||
... | |||
</div> | |||
); | |||
}; | |||
const Speakers = (props) => { | |||
return ( | |||
<DataProvider> | |||
<SpeakersComponent {...props}></SpeakersComponent> | |||
</DataProvider> | |||
); | |||
}; | |||
export default Speakers; | |||
</syntaxhighlight> | |||
==Refactoring Of DatabaseContext== | |||
===Rename and Add State=== | |||
Now we understand the context it is time to refactor | |||
Originally we had this | |||
<syntaxhighlight lang="js"> | |||
import React, { createContext } from 'react'; | |||
const SpeakersContext = createContext(); | |||
const SpeakersProvider = ({ children }) => { | |||
const speakers = [ | |||
{ imageSrc: 'speaker-component-1124', name: 'Douglas Crockford' }, | |||
{ imageSrc: 'speaker-component-1530', name: 'Tamara Baker' }, | |||
{ imageSrc: 'speaker-component-10803', name: 'Eugene Chuvyrov' }, | |||
]; | |||
return ( | |||
<SpeakersContext.Provider value={speakers}> | |||
{children} | |||
</SpeakersContext.Provider> | |||
); | |||
}; | |||
export { SpeakersContext, SpeakersProvider }; | |||
</syntaxhighlight> | |||
<br> | |||
Lets move the data into a state object along with the status of the data loading. e.g. success and failure and rename to DataProvider | |||
<syntaxhighlight lang="js"> | |||
import React, { createContext } from 'react'; | |||
const DataContext = createContext(); | |||
const DataProvider = ({ children }) => { | |||
const speakers = [ | |||
.... | |||
] | |||
const state = { | |||
speakers: speakers, | |||
status: 'success' | |||
} | |||
return ( | |||
<SpeakersContext.Provider value={state}> | |||
{children} | |||
</SpeakersContext.Provider> | |||
); | |||
}; | |||
export { DataContext, DataProvider }; | |||
</syntaxhighlight> | |||
===Add UseState and UseEffect=== | |||
We do this to allow the use of UseState and we use UseEffect to allow the state to be changed. We will state to see a component which has much of it's code using hooks. | |||
<syntaxhighlight lang="js"> | |||
... | |||
const [state, setState] = useState({ | |||
speakers: speakers, | |||
status: 'loading' | |||
}) | |||
useEffect(() => { | |||
const timer = setTimeout(() => { | |||
setState({ | |||
speakers: speakers, | |||
status: 'success', | |||
error: undefined, | |||
}) | |||
}, 1000) | |||
return () => clearTimeout(timer) | |||
}, []) | |||
return ( | |||
<SpeakersContext.Provider value={state}> | |||
{children} | |||
</SpeakersContext.Provider> | |||
); | |||
}; | |||
export { DataContext, DataProvider }; | |||
</syntaxhighlight> | |||
===Custom Hook=== | |||
Now we see the list of hooks being used we can put these in our own hook which is just a function starting with use. First here is our custom hook which is the same code just in a different file. I think it is great to put it in a different file but there seems to be no framework around this just a choice which can be open to interpretation. | |||
<syntaxhighlight lang="js"> | |||
import React, { useState, useEffect } from 'react'; | |||
const useRequestSimple = () => { | |||
const speakers = [ | |||
... | |||
]; | |||
const [state, setState] = useState({ | |||
speakers: speakers, | |||
status: 'loading', | |||
}); | |||
useEffect(() => { | |||
const timer = setTimeout(() => { | |||
setState({ | |||
speakers: speakers, | |||
status: 'success', | |||
error: undefined, | |||
}); | |||
}, 1000); | |||
return () => clearTimeout(timer); | |||
}, []); | |||
return state; | |||
}; | |||
export default useRequestSimple; | |||
</syntaxhighlight> | |||
<br> | |||
And the now easy to manage DataContext | |||
<syntaxhighlight lang="js"> | |||
import React, { createContext } from 'react'; | |||
import useRequestSimple from '../hooks/useRequestSimple'; | |||
const DataContext = createContext(); | |||
const DataProvider = ({ children }) => { | |||
const state = useRequestSimple(); | |||
return <DataContext.Provider value={state}>{children}</DataContext.Provider>; | |||
}; | |||
export { DataContext, DataProvider }; | |||
</syntaxhighlight> | |||
===Final Solution=== | |||
We need to implement the REST aspect into the Context solution. Most of this involved moving the original HOC around to build a custom hook UseRequest. Here is the code at the end of refactoring | |||
<syntaxhighlight lang="js"> | |||
import React, { useReducer, useEffect } from 'react'; | |||
import requestReducer, { REQUEST_STATUS } from '../reducers/request'; | |||
import axios from 'axios'; | |||
import { | |||
GET_ALL_FAILURE, | |||
GET_ALL_SUCCESS, | |||
PUT_FAILURE, | |||
PUT_SUCCESS, | |||
} from '../actions/request'; | |||
const useRequest = (baseUrl, routeName) => { | |||
const [{ records, status, error }, dispatch] = useReducer(requestReducer, { | |||
status: REQUEST_STATUS.LOADING, | |||
records: [], | |||
error: null, | |||
}); | |||
useEffect(() => { | |||
const fetchData = async () => { | |||
try { | |||
const response = await axios.get(`${baseUrl}/${routeName}`); | |||
dispatch({ | |||
type: GET_ALL_SUCCESS, | |||
records: response.data, | |||
}); | |||
} catch (e) { | |||
console.log('Loading data error', e); | |||
dispatch({ | |||
type: GET_ALL_FAILURE, | |||
error: e, | |||
}); | |||
} | |||
}; | |||
fetchData(); | |||
}, [baseUrl, routeName]); | |||
const propsLocal = { | |||
records, | |||
status, | |||
error, | |||
put: async (record) => { | |||
try { | |||
await axios.put(`${baseUrl}/${routeName}/${record.id}`, record); | |||
dispatch({ | |||
type: PUT_SUCCESS, | |||
record: record, | |||
}); | |||
} catch (e) { | |||
dispatch({ | |||
type: PUT_FAILURE, | |||
error: e, | |||
}); | |||
} | |||
}, | |||
}; | |||
return propsLocal; | |||
}; | |||
export default useRequest; | |||
</syntaxhighlight> | |||
From this you can see that useRequest takes the same parameters as input, baseUrl and RouteName and returns the same propsLocal, And here is the final DataContext | |||
<syntaxhighlight lang="js"> | |||
import React, { createContext } from 'react'; | |||
import useRequest from '../hooks/useRequest'; | |||
const DataContext = createContext(); | |||
const DataProvider = ({ children, baseUrl, routeName }) => { | |||
const state = useRequest(baseUrl, routeName); | |||
return <DataContext.Provider value={state}>{children}</DataContext.Provider>; | |||
}; | |||
export { DataContext, DataProvider }; | |||
</syntaxhighlight> | |||
==Theme Example With Context== | |||
===Create a context with a customer hook useTheme=== | |||
<syntaxhighlight lang="js"> | |||
import React from 'react'; | |||
import useTheme from '../hooks/useTheme'; | |||
const THEMELIST = { | |||
DARK: 'dark', | |||
LIGHT: 'light', | |||
}; | |||
const ThemeContext = React.createContext(); | |||
const ThemeProvider = ({ children, startingTheme }) => { | |||
const state = useTheme(startingTheme); | |||
return ( | |||
<ThemeContext.Provider value={state}>{children}</ThemeContext.Provider> | |||
); | |||
}; | |||
export { ThemeContext, ThemeProvider, THEMELIST }; | |||
</syntaxhighlight> | |||
===Implement Hook useTheme=== | |||
Returns the theme and the function to toggle it. | |||
<syntaxhighlight lang="js"> | |||
import React, { useState } from 'react'; | |||
import { THEMELIST } from '../contexts/ThemeContext'; | |||
const useTheme = (startingTheme) => { | |||
const [theme, setTheme] = useState(startingTheme); | |||
return { | |||
theme, | |||
toggleTheme: () => { | |||
if (theme === THEMELIST.DARK) { | |||
setTheme(THEMELIST.LIGHT); | |||
} else { | |||
setTheme(THEMELIST.DARK); | |||
} | |||
}, | |||
}; | |||
}; | |||
export default useTheme; | |||
</syntaxhighlight> | |||
===Implement Theme in the Layout=== | |||
Read the context and set the appropriate class on the div | |||
<syntaxhighlight lang="js"> | |||
import React, { useContext } from 'react'; | |||
import Header from '../Header/Header'; | |||
import Menu from '../Menu/Menu'; | |||
import Footer from '../Footer/Footer'; | |||
import { | |||
ThemeContext, | |||
THEMELIST, | |||
ThemeProvider, | |||
} from '../../contexts/ThemeContext'; | |||
const LayoutComponent = ({ children }) => { | |||
const { theme } = useContext(ThemeContext); | |||
const classNameValue = | |||
theme === THEMELIST.LIGHT | |||
? 'overflow-auto bg-white' | |||
: 'overflow-auto bg-gray-700'; | |||
return ( | |||
<div className={classNameValue}> | |||
<div className="mx-4 my-3"> | |||
<Header /> | |||
<Menu /> | |||
{children} | |||
<Footer /> | |||
</div> | |||
</div> | |||
); | |||
}; | |||
const Layout = ({ children }) => ( | |||
<ThemeProvider startingTheme={THEMELIST.LIGHT}> | |||
<LayoutComponent>{children}</LayoutComponent> | |||
</ThemeProvider> | |||
); | |||
export default Layout; | |||
</syntaxhighlight> | |||
===Implement Toggle on Menu=== | |||
Read the context and call the function when button pressed | |||
<syntaxhighlight lang="js"> | |||
import React, { useContext } from 'react'; | |||
import Link from 'next/link'; | |||
import { ThemeContext } from '../../contexts/ThemeContext'; | |||
const Menu = () => { | |||
const { toggleTheme } = useContext(ThemeContext); | |||
return ( | |||
<nav className="flex items-center justify-between flex-wrap bg-gray-800 mb-6 px-6 h-16 rounded-md"> | |||
<div className="w-full flex flex-grow"> | |||
<div className="flex items-center flex-grow"> | |||
<Link href="/"> | |||
<a className="block md:inline-block text-gray-300 hover:text-white mr-4"> | |||
Home | |||
</a> | |||
</Link> | |||
<Link href="/speakers"> | |||
<a className="block md:inline-block text-gray-300 hover:text-white mr-4"> | |||
Speakers | |||
</a> | |||
</Link> | |||
</div> | |||
<button | |||
onClick={() => { | |||
toggleTheme(); | |||
}} | |||
className="hover:text-blue-500 font-semibold py-1 px-2 rounded focus:outline-none bg-black text-white" | |||
> | |||
Toggle Theme | |||
</button> | |||
</div> | |||
</nav> | |||
); | |||
}; | |||
export default Menu; | |||
</syntaxhighlight> | |||
=Performance And Monitoring= | |||
==Profiler== | |||
This provides an overview of performance using a Flamegraph, how we rename things. | |||
[[File: React Profile.png| 900px]] | |||
==Tracking Down An Issue== | |||
===Run the Profiler=== | |||
What was interesting was when we toggled the button for favoured speaker the Flamegraph showed that the SpeakerImage was rendered 13 times (one for each speaker). So understanding what should be rendered is probably the important thing to know. | |||
<br> | |||
Adding a console statement in the rendering clearly shows that this is true. | |||
===Adding Detail=== | |||
Under the Settings Cog on the Profilier you will see the following. | |||
Select "Record why each component rendered while profiling" and it will show the answer on the next run | |||
[[File:React Profier Detail.png| 400px]] | |||
===And the Answer Is==== | |||
So here is the answer, Props change of onFavouriteToggle | |||
[[File:React Profier Detail2.png| 400px]] | |||
===Solving the Issue=== | |||
This was solved by using React.memo and React.useCallback. This does seem like a convoluted approach with a lot of onus on the developer. The full code is provided below. | |||
*React.memo wrapper on the Speaker | |||
<syntaxhighlight lang="js"> | |||
import React from 'react'; | |||
import SpeakerImage from '../SpeakerImage/SpeakerImage'; | |||
import SpeakerFavoriteButton from '../SpeakerFavoriteButton/SpeakerFavoriteButton'; | |||
const Speaker = React.memo(({ | |||
id, | |||
bio, | |||
firstName, | |||
lastName, | |||
isFavorite, | |||
put | |||
}) => ( | |||
... | |||
<div className="rounded overflow-hidden shadow-lg p-6 bg-white"> | |||
<div className="grid grid-cols-4 mb-6"> | |||
<div className="font-bold text-lg col-span-3">{`${firstName} ${lastName}`}</div> | |||
<div className="flex justify-end"> | |||
<SpeakerFavoriteButton | |||
isFavorite={isFavorite} | |||
onFavoriteToggle={() => { | |||
put({ | |||
id, | |||
firstName, | |||
lastName, | |||
bio, | |||
isFavorite: !isFavorite, | |||
}); | |||
}} | |||
/> | |||
</div> | |||
</div> | |||
<div className="mb-6"> | |||
<SpeakerImage id={id} /> | |||
</div> | |||
<div>{bio.substr(0, 70) + '...'}</div> | |||
</div> | |||
)); | |||
export default Speaker; | |||
</syntaxhighlight> | |||
*React.useCallback wrapper for the put function (update favourite) | |||
<syntaxhighlight lang="js"> | |||
import React, { useReducer, useEffect } from 'react'; | |||
import requestReducer, { REQUEST_STATUS } from '../reducers/request'; | |||
import axios from 'axios'; | |||
import { | |||
GET_ALL_FAILURE, | |||
GET_ALL_SUCCESS, | |||
PUT_FAILURE, | |||
PUT_SUCCESS, | |||
} from '../actions/request'; | |||
const useRequest = (baseUrl, routeName) => { | |||
const [{ records, status, error }, dispatch] = useReducer(requestReducer, { | |||
status: REQUEST_STATUS.LOADING, | |||
records: [], | |||
error: null, | |||
}); | |||
useEffect(() => { | |||
const fetchData = async () => { | |||
try { | |||
const response = await axios.get(`${baseUrl}/${routeName}`); | |||
dispatch({ | |||
type: GET_ALL_SUCCESS, | |||
records: response.data, | |||
}); | |||
} catch (e) { | |||
console.log('Loading data error', e); | |||
dispatch({ | |||
type: GET_ALL_FAILURE, | |||
error: e, | |||
}); | |||
} | |||
}; | |||
fetchData(); | |||
}, [baseUrl, routeName]); | |||
const propsLocal = { | |||
records, | |||
status, | |||
error, | |||
put: React.useCallback(async (record) => { | |||
try { | |||
await axios.put(`${baseUrl}/${routeName}/${record.id}`, record); | |||
dispatch({ | |||
type: PUT_SUCCESS, | |||
record: record, | |||
}); | |||
} catch (e) { | |||
dispatch({ | |||
type: PUT_FAILURE, | |||
error: e, | |||
}); | |||
} | |||
},[]), | |||
}; | |||
return propsLocal; | |||
}; | |||
export default useRequest; | |||
</syntaxhighlight> | |||
=Component Techniques= | |||
==Managing REST Resources== | |||
When we use async function it is important to clean up the resources in the event of failure or the use navigating away from the page. Separation of the code makes this a lot easier. For our REST implementation we need to manage the axios calls. For our fix we get a cancel token and pass it into the get request. You also need to call cancel on the token on the clean up method. Here is the code. | |||
<syntaxhighlight lang="js"> | |||
import React, { useReducer, useEffect } from 'react'; | |||
import requestReducer, { REQUEST_STATUS } from '../reducers/request'; | |||
import axios from 'axios'; | |||
import { | |||
GET_ALL_FAILURE, | |||
GET_ALL_SUCCESS, | |||
PUT_FAILURE, | |||
PUT_SUCCESS, | |||
} from '../actions/request'; | |||
const useRequest = (baseUrl, routeName) => { | |||
const [{ records, status, error }, dispatch] = useReducer(requestReducer, { | |||
status: REQUEST_STATUS.LOADING, | |||
records: [], | |||
error: null, | |||
}); | |||
const signal = React.useRef(axios.CancelToken.source()); | |||
useEffect(() => { | |||
const fetchData = async () => { | |||
try { | |||
const response = await await axios.get(`${baseUrl}/${routeName}`, { | |||
cancelToken: signal.current.token, | |||
}); | |||
dispatch({ | |||
type: GET_ALL_SUCCESS, | |||
records: response.data, | |||
}); | |||
} catch (e) { | |||
console.log('Loading data error', e); | |||
if (axios.isCancel(e)) { | |||
console.log('Get request canceled'); | |||
} else { | |||
dispatch({ | |||
type: GET_ALL_FAILURE, | |||
error: e, | |||
}); | |||
} | |||
} | |||
}; | |||
fetchData(); | |||
return () => { | |||
console.log('unmount and cancel running axios request'); | |||
signal.current.cancel(); | |||
}; | |||
}, [baseUrl, routeName]); | |||
const propsLocal = { | |||
records, | |||
status, | |||
error, | |||
put: React.useCallback(async (record) => { | |||
try { | |||
await axios.put(`${baseUrl}/${routeName}/${record.id}`, record); | |||
dispatch({ | |||
type: PUT_SUCCESS, | |||
record: record, | |||
}); | |||
} catch (e) { | |||
dispatch({ | |||
type: PUT_FAILURE, | |||
error: e, | |||
}); | |||
} | |||
},[]), | |||
}; | |||
return propsLocal; | |||
}; | |||
export default useRequest; | |||
</syntaxhighlight> | </syntaxhighlight> |
Latest revision as of 02:31, 24 June 2021
Designing Components
Resources
These can found here https://github.com/pkellner/pluralsight-designing-react-components-course-code Implements
- Component Reuse
- Single Responsibility
- Dont Repeat Yourself
Next JS Setup
Create project with
npm install react react-dom next --save
Add three commands to packages.json
"dev": "next",
"build": "next build",
"start": "next start"
Basic Page
Example Page
In the demo the tutor builds an app which looks like this
Next JS Page
We can create a next js page by returning JSX which represents the components we want on our page. Each line of the div can represent the components we may like to see on our page. E.g. Header, Menu, Search Bar, Speakers and Footer
function Page() {
return (
<div>
<img src="images/header.png" />
<img src="images/menu.gif" />
<img src="images/searchbar.gif" />
<img src="images/speakers.png" />
<img src="images/footer.png" />
</div>
)
}
export default Page
Replacing Next JS Page with Components
Introduction
Here we have replaced the images with React components for the page.
function Page() {
return (
<div>
<Header />
<Menu />
<SpeakerSearchBar />
<Speakers />
<Footer />
</div>
)
}
export default Page
Header Component
Here is the example Header Component
import React from 'react'
const Header = () => <img src="images/header.png" />
export default Header
Speaker Component
This component combines the fixed data with the rendering in one component. A good start for a prototype but lets look at abstracting the component below.
const Speakers = () => {
const speakers = [
{ image: "images/speaker-component-1124.png", name: "a" },
{ image: "images/speaker-component-1530.png", name: "b" },
{ image: "images/speaker-component-10803.png", name: "c" },
];
return (
<div>
{speakers.map((x) => {
return <img src={x.image} alt={x.name} key={x.image} />;
})}
</div>
);
};
export default Speakers;
Component Abstractions
In programming three patterns used to abstract
- HOC - Higher Order Component
A higher-order component is a function that takes a component and returns a new component
- RP - Render Prop
A RP is simply a prop that takes a function which returns elements that will be used in render(). You can pass an element directly into a prop and use it in render() which would make the whole thing a RP by name, but generally, when people speak about RPs, they mean the first definition.
- Context
Context in React is used to share data that is global to a component tree such as an authenticated user or preferred theme without passing that data as props.
Higher-Order Component HOC
Simple Example of HOC
As specified above a HOC is a function that takes a component and returns a new component
const EnhancedSpeakerComponent = withData(Speakers)
function withData(Component) {
return function() {
return <Component />
}
}
export default EnhancedSpeakerComponent
Move the Data to HOC
Now lets move our array into the HOC to have
const EnhancedSpeakerComponent = withData(Speakers)
function withData(Component) {
const speakers = [
{ image: "images/speaker-component-1124.png", name: "a" },
{ image: "images/speaker-component-1530.png", name: "b" },
{ image: "images/speaker-component-10803.png", name: "c" },
]
return function() {
return <Component speakers={speakers} />
}
}
export default EnhancedSpeakerComponent
And change the component to have speakers as a prop
const Speakers = ({speakers}) => {
return (
<div>
{speakers.map((x) => {
return <img src={x.image} alt={x.name} key={x.image} />;
})}
</div>
);
};
We can now put the withData in a separate function and import into the Speakers component so we now have.
import React from 'react'
import withData from './withData'
const Speakers = ({speakers}) => {
return (
<div>
{speakers.map((x) => {
return <img src={x.image} alt={x.name} key={x.image} />;
})}
</div>
);
};
export default withData(Speakers)
Adding Parameter to Our HOC
Lets say we want to limit the number of images. We can do this but passing a value to the function like below
const maxSpeakersToShow = 2
export default withData(maxSpeakersToShow)(Speakers)
This is very similar to how we implement decorators in python, java or other languages because to implement this in the function we simply wrap the function and return it
function withData(maxSpeakersToShow) {
return function(Component) {
...
}
}
Here is the full example.
import React from "react";
function withData(maxSpeakersToShow) {
return function(Component) {
const speakers = [
{ image: "images/speaker-component-1124.png", name: "a" },
{ image: "images/speaker-component-1530.png", name: "b" },
{ image: "images/speaker-component-10803.png", name: "c" },
];
return function () {
const limitSpeakers = speakers.slice(0, maxSpeakersToShow)
return <Component speakers={limitSpeakers} />;
};
}
}
export default withData;
Let use the more modern format with lambdas.
const withData = (maxSpeakersToShow) => (Component) => {
const speakers = [
{ image: "images/speaker-component-1124.png", name: "a" },
{ image: "images/speaker-component-1530.png", name: "b" },
{ image: "images/speaker-component-10803.png", name: "c" },
]
return () => {
const limitSpeakers = speakers.slice(0, maxSpeakersToShow);
return <Component speakers={limitSpeakers} />;
}
}
Render Props
Boilerplate Code
Originally we had
const Speakers = () => {
const speakers = [
{ image: "images/speaker-component-1124.png", name: "a" },
{ image: "images/speaker-component-1530.png", name: "b" },
{ image: "images/speaker-component-10803.png", name: "c" },
];
return (
<div>
{speakers.map((x) => {
return <img src={x.image} alt={x.name} key={x.image} />;
})}
</div>
);
};
With Render Prop we can abstract the rendering with a function. A react function takes the argument of props and returns the child of the props
function speakersRenderProps(props) {
return props.children()
}
const Speakers = () => {
const speakers = [
{ image: "images/speaker-component-1124.png", name: "a" },
{ image: "images/speaker-component-1530.png", name: "b" },
{ image: "images/speaker-component-10803.png", name: "c" },
];
return (
<SpeakerRenderProps>
{() => {
return (
<div>
{speakers.map((x) => {
return <img src={x.image} alt={x.name} key={x.image} />;
})}
</div>
)
}}
</SpeakerRenderProps>
);
};
Finished Solution
We can now abstract the code into it's own file and pass the prop from the original component to the abstraction to render the speakers.
Speaker Component
const Speakers = () => {
return (
<SpeakerRenderProps>
{({speakers}) => {
return (
<div>
{speakers.map((x) => {
return <img src={x.image} alt={x.name} key={x.image} />;
})}
</div>
)
}}
</SpeakerRenderProps>
);
};
Speaker Render Component
function SpeakersRenderProps(props) {
const speakers = [
{ image: "images/speaker-component-1124.png", name: "a" },
{ image: "images/speaker-component-1530.png", name: "b" },
{ image: "images/speaker-component-10803.png", name: "c" },
];
return props.children({
speakers: speakers
})
}
export default SpeakersRenderProps
Context
This is the simplest way to share data. We create a context with
import React from 'react';
const SpeakerContext = React.createContext({})
export default SpeakerContext
Add the context around the components to have access
function Page() {
return (
<div>
<Header />
<Menu />
<SpeakerContext.Provider value={speakers}>
<SpeakerSearchBar />
<Speakers />
</SpeakerContext.Provider>
<Footer />
</div>
)
}
Use the context in the component with the hook useContext
import SpeakerContext from './SpeakerContext'
const Speakers = () => {
const speakers = useContext(SpeakerContext)
return (
<div>
{speakers.map((x) => {
return <img src={x.image} alt={x.name} key={x.image} />;
})}
</div>
);
};
TailWind
Set up
This is a bit of a departure from the topic but useful to have some exposure to tailwind.
npm i @fullhuman/postcss-purgecss postcss-preset-env tailwindcss --save-dev
npx tailwindcss init -p
Replace purge[] in tailwind.config.js with
purge: ['./pages/**/*.js', './components/**/*.js'],
Then create styles/globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;
And create pages/_app.js
// pages/_app.js
import '../styles/globals.css'
function MyApp({ Component, pageProps }) {
return <Component {...pageProps} />
}
export default MyApp
global.css
We can create class which can be applied to html in here. For example
.component-highlight {
hidden: true;
border-radius: 0.4rem !important;
border: 5px solid #9bc850 !important;
margin-top: 10px;
margin-bottom: 10px;
margin-left: 5px;
margin-right: 5px;
}
.component-sub-highlight {
hidden: true;
border-radius: 0.4rem !important;
border: 4px solid #675ba7 !important;
margin-top: 10px;
margin-bottom: 10px;
margin-left: 5px;
margin-right: 5px;
}
Combining Classes with Tailwind
We can create a file button.css under styles and then combine classes using the @apply keyword in a class
.btn-blue {
@apply bg-blue-500 text-white font-bold py-2 px-4 rounded;
}
The btn-blue class can then be used like any other class.
Refactoring into Components
Lazy Loading and The Fold
There is a concept of the fold similar to newspapers where they place an interesting part of the story on the top half of the newspaper. Where to put the fold depends on the audience
- Certain visitors
Certain visitors are those who are likely to react to the call to action as they have largely made their mind up before visiting the site. This is where known brands have an advantage, as there is little for the “certain visitor” to learn about a product or service. In these cases, placing a call to action above the fold is only a matter of convenience.
- Uncertain visitors that are familiar with your product – or the proposition is simple
For uncertain visitors that understand the call to action simply or that have some knowledge of the product or service, placing the call to action above the fold is generally best practice for much the same reasons as those listed above, although the informative content is also important.
- Uncertain visitors that are presented with a complex proposition
For uncertain visitors that are presented with a complex proposition, such as a product or service that isn’t obviously beneficial to them, placing the call to action above the fold will not suffice. What is required is a more in-depth explanation of why your call to action should be acted upon. In fact, placing your call to action up front can appear a little pushy.
For react there is a package called react-simpl-img which will take care of this for us but there are of course alternatives. To implement this we separate the speaker image into its own component.
import React from 'react';
import { SimpleImg } from 'react-simple-img';
function SpeakerImage({ id }) {
const imageUrl = `/speakers/speaker-${id}.jpg`;
return (
<SimpleImg
src={imageUrl}
animationDuration="1"
width={200}
height={200}
applyAspectRatio="true"
/>
);
}
export default SpeakerImage;
@jmperezperez provided a component to detect if visible
class Observer extends Component {
constructor() {
super();
this.state = { isVisible: false };
this.io = null;
this.container = null;
}
componentDidMount() {
this.io = new IntersectionObserver([entry] => {
this.setState({ isVisible: entry.isIntersecting });
}, {});
this.io.observe(this.container);
}
componentWillUnmount() {
if (this.io) {
this.io.disconnect();
}
}
render() {
return (
// we create a div to get a reference.
// It's possible to use findDOMNode() to avoid
// creating extra elements, but findDOMNode is discouraged
<div
ref={div => {
this.container = div;
}}
>
{Array.isArray(this.props.children)
? this.props.children.map(child => child(this.state.isVisible))
: this.props.children(this.state.isVisible)}
</div>
);
}
}
Implementing Filtering
To add filtering we use the useState hook in the Speakers page. We passed the value and the setter to the SpeakerSearchBar.
const SpeakerSearchBar = ({ searchQuery, setSearchQuery }) => (
<div className="mb-6">
<input
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
id="username"
type="text"
placeholder="Search by name"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
);
export default SpeakerSearchBar;
And now add the filtering changing
<div className="grid md:grid-cols-2 lg:grid-cols-3 grid-cols-1 gap-12">
{speakers
.map((speaker) => (
<Speaker key={speaker.id} {...speaker} />
))}
</div>
To add the filtering
<div className="grid md:grid-cols-2 lg:grid-cols-3 grid-cols-1 gap-12">
{speakers
.filter((rec) => {
const targetString = `${rec.firstName} ${rec.lastName}`.toLowerCase();
return searchQuery.length === 0
? true
: targetString.includes(searchQuery.toLowerCase());
})
.map((speaker) => (
<Speaker key={speaker.id} {...speaker} />
))}
</div>
Using State to update
This is a problem I encountered with the play state of a track. There is the original data (tracks) and the current state which could be playing, stopped, paused, neither. For the speaker app we are toggling a favourite button against a speaker and the solution was to
- Put the data into a state
- Merge the data when changed (onFavoriteToggleHandler)
- Pass the handler to the component
// Splits the record and toggles the isFavourite
function toggleSpeakerFavorite(speakerRec) {
return {
...speakerRec,
isFavorite: !speakerRec.isFavorite,
};
}
// Replaces the record in the state for this record
// Like the use of slice for replacing
function onFavoriteToggleHandler(speakerRec) {
const toggledSpeakerRec = toggleSpeakerFavorite(speakerRec)
const speakerIndex = speakers
.map((speaker) => speaker.id)
.indexOf(speakerRec.id);
setSpeakers([
...speakers.slice(0, speakerIndex),
toggledSpeakerRec,
...speakers.slice(speakerIndex + 1),
]);
}
Implementing REST
Not many surprises we are going to be using axios for this.
Add GET Request
Using the useEffect we create a function to fetch the data
useEffect(() => {
const fetchData = async () => {
try {
const response = await axios.get('http://localhost:4000/speakers');
setSpeakers(response.data);
setStatus(REQUEST_STATUS.SUCCESS);
} catch (e) {
setStatus(REQUEST_STATUS.ERROR);
setError(e);
}
};
fetchData();
}, []);
Add PUT Request
We change the previous useState to implement a PUT request
async function onFavoriteToggleHandler(speakerRec) {
const toggledSpeakerRec = toggleSpeakerFavorite(speakerRec);
const speakerIndex = speakers
.map((speaker) => speaker.id)
.indexOf(speakerRec.id);
try {
await axios.put(
`http://localhost:4000/speakers/${speakerRec.id}`,
toggledSpeakerRec,
);
setSpeakers([
...speakers.slice(0, speakerIndex),
toggledSpeakerRec,
...speakers.slice(speakerIndex + 1),
]);
} catch (e) {
setStatus(REQUEST_STATUS.ERROR);
setError(e);
}
}
Add Json server to the project
Add the package json-server and add an additional script
"json-server": "json-server --watch db.json --port 4000 --delay 1000"
Create some data
{
"speakers": [
{
"id": 1530,
"firstName": "Tamara",
"lastName": "Baker",
"sat": false,
"sun": true,
"isFavorite": false,
"bio": "Tammy has held a number of executive and management roles over the past 15 years, including VP engineering Roles at Molekule Inc., Cantaloupe Systems, E-Color, and Untangle Inc."
},
{
"id": 5996,
"firstName": "Craig",
"lastName": "Berntson",
....
Showing Status
In Angular we used templates and *ngIf and names for react we do the following
...
{isLoading && <div>Loading...</div>}
{hasErrored && (
<div>
Loading error... Is the json-server running? (try "npm run
json-server" at terminal prompt)
<br />
<b>ERROR: {error.message}</b>
</div>
)}
{success && (
<div className="grid md:grid-cols-2 lg:grid-cols-3 grid-cols-1 gap-12">
{speakers
...
Implementing Reducers
In the code we are managing three lots of state. This is a good case for using a reducer (redux).
Before Code
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import SpeakerSearchBar from '../SpeakerSearchBar/SpeakerSearchBar';
import Speaker from '../Speaker/Speaker';
const Speakers = () => {
function toggleSpeakerFavorite(speakerRec) {
return {
...speakerRec,
isFavorite: !speakerRec.isFavorite,
};
}
async function onFavoriteToggleHandler(speakerRec) {
const toggledSpeakerRec = toggleSpeakerFavorite(speakerRec);
const speakerIndex = speakers
.map((speaker) => speaker.id)
.indexOf(speakerRec.id);
try {
await axios.put(
`http://localhost:4000/speakers/${speakerRec.id}`,
toggledSpeakerRec,
);
setSpeakers([
...speakers.slice(0, speakerIndex),
toggledSpeakerRec,
...speakers.slice(speakerIndex + 1),
]);
} catch (e) {
setStatus(REQUEST_STATUS.ERROR);
setError(e);
}
}
const [searchQuery, setSearchQuery] = useState('');
const [speakers, setSpeakers] = useState([]);
const REQUEST_STATUS = {
LOADING: 'loading',
SUCCESS: 'success',
ERROR: 'error',
};
const [status, setStatus] = useState(REQUEST_STATUS.LOADING);
const [error, setError] = useState({});
useEffect(() => {
const fetchData = async () => {
try {
const response = await axios.get('http://localhost:4000/speakers/');
setSpeakers(response.data);
setStatus(REQUEST_STATUS.SUCCESS);
} catch (e) {
console.log('Loading data error', e);
setStatus(REQUEST_STATUS.ERROR);
setError(e);
}
};
fetchData();
}, []);
const success = status === REQUEST_STATUS.SUCCESS;
const isLoading = status === REQUEST_STATUS.LOADING;
const hasErrored = status === REQUEST_STATUS.ERROR;
return (
<div>
<SpeakerSearchBar
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
/>
{isLoading && <div>Loading...</div>}
{hasErrored && (
<div>
Loading error... Is the json-server running? (try "npm run
json-server" at terminal prompt)
<br />
<b>ERROR: {error.message}</b>
</div>
)}
{success && (
<div className="grid md:grid-cols-2 lg:grid-cols-3 grid-cols-1 gap-12">
{speakers
.filter((rec) => {
const targetString = `${rec.firstName} ${rec.lastName}`.toLowerCase();
return searchQuery.length === 0
? true
: targetString.includes(searchQuery.toLowerCase());
})
.map((speaker) => (
<Speaker
key={speaker.id}
{...speaker}
onFavoriteToggle={() => onFavoriteToggleHandler(speaker)}
/>
))}
</div>
)}
</div>
);
};
export default Speakers;
After code
import React, { useState, useEffect, useReducer } from 'react';
import axios from 'axios';
import SpeakerSearchBar from '../SpeakerSearchBar/SpeakerSearchBar';
import Speaker from '../Speaker/Speaker';
const Speakers = () => {
function toggleSpeakerFavorite(speakerRec) {
return {
...speakerRec,
isFavorite: !speakerRec.isFavorite,
};
}
async function onFavoriteToggleHandler(speakerRec) {
const toggledSpeakerRec = toggleSpeakerFavorite(speakerRec);
const speakerIndex = speakers
.map((speaker) => speaker.id)
.indexOf(speakerRec.id);
try {
await axios.put(
`http://localhost:4000/speakers/${speakerRec.id}`,
toggledSpeakerRec,
);
setSpeakers([
...speakers.slice(0, speakerIndex),
toggledSpeakerRec,
...speakers.slice(speakerIndex + 1),
]);
} catch (e) {
setStatus(REQUEST_STATUS.ERROR);
setError(e);
}
}
const [searchQuery, setSearchQuery] = useState('');
const REQUEST_STATUS = {
LOADING: 'loading',
SUCCESS: 'success',
ERROR: 'error',
};
const reducer = (state, action) => {
switch (action.type) {
case 'GET_ALL_SUCCESS':
return {
...state,
status: REQUEST_STATUS.SUCCESS,
speakers: action.speakers,
};
case 'UPDATE_STATUS':
return {
...state,
status: action.status,
};
}
};
const [{ speakers, status }, dispatch] = useReducer(reducer, {
status: REQUEST_STATUS.LOADING,
speakers: [],
});
const [error, setError] = useState({});
useEffect(() => {
const fetchData = async () => {
try {
const response = await axios.get('http://localhost:4000/speakers/');
dispatch({
speakers: response.data,
type: 'GET_ALL_SUCCESS',
});
} catch (e) {
console.log('Loading data error', e);
dispatch({
status: REQUEST_STATUS.ERROR,
type: 'UPDATE_STATUS',
});
setError(e);
}
};
fetchData();
}, []);
const success = status === REQUEST_STATUS.SUCCESS;
const isLoading = status === REQUEST_STATUS.LOADING;
const hasErrored = status === REQUEST_STATUS.ERROR;
return (
<div>
<SpeakerSearchBar
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
/>
{isLoading && <div>Loading...</div>}
{hasErrored && (
<div>
Loading error... Is the json-server running? (try "npm run
json-server" at terminal prompt)
<br />
<b>ERROR: {error.message}</b>
</div>
)}
{success && (
<div className="grid md:grid-cols-2 lg:grid-cols-3 grid-cols-1 gap-12">
{speakers
.filter((rec) => {
const targetString = `${rec.firstName} ${rec.lastName}`.toLowerCase();
return searchQuery.length === 0
? true
: targetString.includes(searchQuery.toLowerCase());
})
.map((speaker) => (
<Speaker
key={speaker.id}
{...speaker}
onFavoriteToggle={() => onFavoriteToggleHandler(speaker)}
/>
))}
</div>
)}
</div>
);
};
export default Speakers;
Final Reducer Code
This shows the final changes. Whilst we will never use the code it does show the approach to take if we want to start using reducers.
Add consts to replace string literals
Add actions/request.js
export const GET_ALL_SUCCESS = 'GET_ALL_SUCCESS';
export const GET_ALL_FAILURE = 'GET_ALL_FAILURE';
export const PUT_SUCCESS = 'PUT_SUCCESS';
export const PUT_FAILURE = 'PUT_FAILURE';
Externalize Reducer
Move reducer code to reducers/request.js
import {
GET_ALL_SUCCESS,
GET_ALL_FAILURE,
PUT_SUCCESS,
PUT_FAILURE,
} from '../actions/request';
export const REQUEST_STATUS = {
LOADING: 'loading',
SUCCESS: 'success',
ERROR: 'error',
};
const requestReducer = (state, action) => {
switch (action.type) {
case GET_ALL_SUCCESS: {
return {
...state,
records: action.records,
status: REQUEST_STATUS.SUCCESS,
};
}
case GET_ALL_FAILURE: {
return {
...state,
status: REQUEST_STATUS.ERROR,
error: action.error,
};
}
case PUT_SUCCESS:
const { records } = state;
const { record } = action;
const recordIndex = records.map((rec) => rec.id).indexOf(record.id);
return {
...state,
records: [
...records.slice(0, recordIndex),
record,
...records.slice(recordIndex + 1),
],
};
case PUT_FAILURE:
console.log(
'PUT_FAILURE: Currently just logging to console without refreshing records list',
);
return {
...state,
error: action.error,
};
default:
return state;
}
};
export default requestReducer;
Remove dead code from Speakers
Remove the dead code and add call to useReducer
import React, { useState, useEffect, useReducer } from 'react';
import axios from 'axios';
import SpeakerSearchBar from '../SpeakerSearchBar/SpeakerSearchBar';
import Speaker from '../Speaker/Speaker';
import requestReducer from '../../reducers/request';
import {
GET_ALL_FAILURE,
GET_ALL_SUCCESS,
PUT_FAILURE,
PUT_SUCCESS,
} from '../../actions/request';
const REQUEST_STATUS = {
LOADING: 'loading',
SUCCESS: 'success',
ERROR: 'error',
};
const Speakers = () => {
const onFavoriteToggleHandler = async (speakerRec) => {
try {
const toggledSpeakerRec = {
...speakerRec,
isFavorite: !speakerRec.isFavorite,
};
await axios.put(
`http://localhost:4000/speakers/${speakerRec.id}`,
toggledSpeakerRec,
);
dispatch({
type: PUT_SUCCESS,
record: toggledSpeakerRec,
});
} catch (e) {
dispatch({
type: PUT_FAILURE,
error: e,
});
}
};
const [searchQuery, setSearchQuery] = useState('');
const [{ records: speakers, status, error }, dispatch] = useReducer(
requestReducer,
{
records: [],
status: REQUEST_STATUS.LOADING,
error: null,
},
);
useEffect(() => {
const fetchData = async () => {
try {
const response = await axios.get('http://localhost:4000/speakers/');
dispatch({
type: GET_ALL_SUCCESS,
records: response.data,
});
} catch (e) {
console.log('Loading data error', e);
dispatch({
type: GET_ALL_FAILURE,
error: e,
});
}
};
fetchData();
}, []);
const success = status === REQUEST_STATUS.SUCCESS;
const isLoading = status === REQUEST_STATUS.LOADING;
const hasErrored = status === REQUEST_STATUS.ERROR;
return (
<div>
<SpeakerSearchBar
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
/>
{isLoading && <div>Loading...</div>}
{hasErrored && (
<div>
Loading error... Is the json-server running? (try "npm run
json-server" at terminal prompt)
<br />
<b>ERROR: {error.message}</b>
</div>
)}
{success && (
<div className="grid md:grid-cols-2 lg:grid-cols-3 grid-cols-1 gap-12">
{speakers
.filter((rec) => {
const targetString = `${rec.firstName} ${rec.lastName}`.toLowerCase();
return searchQuery.length === 0
? true
: targetString.includes(searchQuery.toLowerCase());
})
.map((speaker) => (
<Speaker
key={speaker.id}
{...speaker}
onFavoriteToggle={() => onFavoriteToggleHandler(speaker)}
/>
))}
</div>
)}
</div>
);
};
export default Speakers;
More HOC and Render Props
Approach
The way to approach creating an HOC is to look at what the rendering component needs in terms of props. In the case of Speakers this was Speakers, Status and Error. Also the Speakers are updated on whether they are favourite or not so adding a function as a parameter will be a way to remove this from the rendering component.
So our HOC will now look like this, the baseURL and routeName are passed from the rendering component (possibly needs to be somewhere else), but the main takeaway is that you look at the outputs and frame your return function around it.
Warning Dont use this
const withRequest = (baseUrl, routeName) => (Component) => () => {
const props = {
records, // Speakers
status,
error,
put, // Function to update speaker
}
return <Component {...props}></Component>
}
export default withRequest
This above approach works but is does not consider that the component being passed may have props. It is did they would be lost. Instead, add the props to the incoming and outgoing so they are not lost and rename the hoc props to avoid collisions
const withRequest = (baseUrl, routeName) => (Component) => (props) => {
const localProps = {
records, // Speakers
status,
error,
put, // Function to update speaker
}
return <Component {...props}{...localProps}></Component>
}
export default withRequest
Multiple HOCs
We may need to have multiple HOC for a component. To implement this you could chain the calls together
export default withSpecialMessage(
withRequest('http://localhost:4000', 'speakers')(Speakers))
But this could get messy so you can use the npm package recompose. Then you can do. Add the import and you can pass them like this
export default compose(
withRequest('http://localhost:4000', 'speakers'),
withSpecialMessage()
)(Speakers)
Final HOC Speaker Component
Lets look at the finished component with two HOCs
import React, { useState } from 'react';
import SpeakerSearchBar from '../SpeakerSearchBar/SpeakerSearchBar';
import Speaker from '../Speaker/Speaker';
import { REQUEST_STATUS } from '../../reducers/request';
import withRequest from '../HOCs/withRequest';
import withSpecialMessage from '../HOCs/withSpecialMessage';
import { compose } from 'recompose';
const Speakers = ({
records: speakers,
status,
error,
put,
bgColor,
specialMessage,
}) => {
const onFavoriteToggleHandler = async (speakerRec) => {
put({
...speakerRec,
isFavorite: !speakerRec.isFavorite,
});
};
const [searchQuery, setSearchQuery] = useState('');
const success = status === REQUEST_STATUS.SUCCESS;
const isLoading = status === REQUEST_STATUS.LOADING;
const hasErrored = status === REQUEST_STATUS.ERROR;
return (
<div className={bgColor}>
<SpeakerSearchBar
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
/>
{specialMessage && specialMessage.length > 0 && (
<div
className="bg-orange-100 border-l-8 border-orange-500 text-orange-700 p-4 text-2xl"
role="alert"
>
<p className="font-bold">Special Message</p>
<p>{specialMessage}</p>
</div>
)}
{isLoading && <div>Loading...</div>}
{hasErrored && (
<div>
Loading error... Is the json-server running? (try "npm run
json-server" at terminal prompt)
<br />
<b>ERROR: {error.message}</b>
</div>
)}
{success && (
<div className="grid md:grid-cols-2 lg:grid-cols-3 grid-cols-1 gap-12">
{speakers
.filter((rec) => {
const targetString = `${rec.firstName} ${rec.lastName}`.toLowerCase();
return searchQuery.length === 0
? true
: targetString.includes(searchQuery.toLowerCase());
})
.map((speaker) => (
<Speaker
key={speaker.id}
{...speaker}
onFavoriteToggle={() => onFavoriteToggleHandler(speaker)}
/>
))}
</div>
)}
</div>
);
};
export default compose(
withRequest('http://localhost:4000', 'speakers'),
withSpecialMessage(),
)(Speakers);
Render Props
Below is the equivalent code using render props. I think it is far more cluttered than the HOC Approach. Firstly the RP functions then then final Speaker component
SpecialMessageRenderProps
For create the special message
function SpecialMessageRenderProps({ children }) {
return children({
// could get this from something like a push notification.
specialMessage: 'Angular Class at 2:45PM Cancelled',
});
}
export default SpecialMessageRenderProps;
Request
For performing the request
import React, { useEffect, useReducer } from 'react';
import axios from 'axios';
import requestReducer, { REQUEST_STATUS } from '../../reducers/request';
import {
PUT_SUCCESS,
PUT_FAILURE,
GET_ALL_SUCCESS,
GET_ALL_FAILURE,
} from '../../actions/request';
const Request = ({ baseUrl, routeName, children }) => {
const [{ records, status, error }, dispatch] = useReducer(requestReducer, {
status: REQUEST_STATUS.LOADING,
records: [],
error: null,
});
useEffect(() => {
const fetchData = async () => {
try {
const response = await axios.get(`${baseUrl}/${routeName}`);
dispatch({
type: GET_ALL_SUCCESS,
records: response.data,
});
} catch (e) {
console.log('Loading data error', e);
dispatch({
type: GET_ALL_FAILURE,
error: e,
});
}
};
fetchData();
}, [baseUrl, routeName]);
const childProps = {
records,
status,
error,
put: async (record) => {
dispatch({
type: 'PUT',
});
try {
await axios.put(`${baseUrl}/${routeName}/${record.id}`, record);
dispatch({
type: PUT_SUCCESS,
record,
});
} catch (e) {
dispatch({
type: PUT_FAILURE,
error: e,
});
}
},
};
return children(childProps);
};
export default Request;
Final Speaker Component in RP
import React, { useState } from 'react';
import SpeakerSearchBar from '../SpeakerSearchBar/SpeakerSearchBar';
import Speaker from '../Speaker/Speaker';
import { REQUEST_STATUS } from '../../reducers/request';
import Request from '../RPs/Request';
import SpecialMessageRenderProps from '../RPs/SpecialMessageRenderProps';
const Speakers = ({ bgColor }) => {
const [searchQuery, setSearchQuery] = useState('');
return (
<div className={bgColor}>
<SpeakerSearchBar
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
/>
<>
<SpecialMessageRenderProps>
{({ specialMessage }) => {
return (
<Request baseUrl="http://localhost:4000" routeName="speakers">
{({ records: speakers, status, error, put }) => {
const onFavoriteToggleHandler = async (speakerRec) => {
put({
...speakerRec,
isFavorite: !speakerRec.isFavorite,
});
};
const success = status === REQUEST_STATUS.SUCCESS;
const isLoading = status === REQUEST_STATUS.LOADING;
const hasErrored = status === REQUEST_STATUS.ERROR;
return (
<>
{specialMessage && specialMessage.length > 0 && (
<div
className="bg-orange-100 border-l-8 border-orange-500 text-orange-700 p-4 text-2xl"
role="alert"
>
<p className="font-bold">Special Message</p>
<p>{specialMessage}</p>
</div>
)}
{isLoading && <div>Loading...</div>}
{hasErrored && (
<div>
Loading error... Is the json-server running? (try "npm
run json-server" at terminal prompt)
<br />
<b>ERROR: {error.message}</b>
</div>
)}
{success && (
<div className="grid md:grid-cols-2 lg:grid-cols-3 grid-cols-1 gap-12">
{speakers
.filter((rec) => {
const targetString = `${rec.firstName} ${rec.lastName}`.toLowerCase();
return searchQuery.length === 0
? true
: targetString.includes(
searchQuery.toLowerCase(),
);
})
.map((speaker) => (
<Speaker
key={speaker.id}
{...speaker}
onFavoriteToggle={() =>
onFavoriteToggleHandler(speaker)
}
/>
))}
</div>
)}
</>
);
}}
</Request>
);
}}
</SpecialMessageRenderProps>
</>
</div>
);
};
export default Speakers;
More Context
Orginal Context
We need to create a more encapsulated context. Previously
import React from 'react';
const SpeakerContext = React.createContext({});
export default SpeakerContext;
And Wrapping the components with the context
export default function Page() {
const speakers = [
{
imageSrc: 'speaker-component-1124',
name: 'Douglas Crockford',
},
{
imageSrc: 'speaker-component-1530',
name: 'Tamara Baker',
},
{
imageSrc: 'speaker-component-10803',
name: 'Eugene Chuvyrov',
},
];
return (
<div>
<Header />
<Menu />
<SpeakerContext.Provider value={speakers}>
<SpeakerSearchBar />
<Speakers />
</SpeakerContext.Provider>
<Footer />
</div>
);
}
Creating a Better Context
This can now be expanded to encapsulate the provider and to manage it's own data
import React, { createContext } from 'react';
const SpeakersContext = createContext();
const SpeakersProvider = ({ children }) => {
const speakers = [
{ imageSrc: 'speaker-component-1124', name: 'Douglas Crockford' },
{ imageSrc: 'speaker-component-1530', name: 'Tamara Baker' },
{ imageSrc: 'speaker-component-10803', name: 'Eugene Chuvyrov' },
];
return (
<SpeakersContext.Provider value={speakers}>
{children}
</SpeakersContext.Provider>
);
};
export { SpeakersContext, SpeakersProvider };
Below is the reworked Speakers component
const Speakers = () => {
return (
<SpeakersProvider>
<SpeakersComponent></SpeakersComponent>
</SpeakersProvider>
);
};
export default Speakers;
Implementing into Speaker Component
We now implement the use of the context into the Speaker context built with the HOC. The context been renamed to DataContext and DataProvider but the rendering code is much the same. The main change is to the export which wraps the component in a context ensuring that any child props are not lost
import React, { useState, useContext } from 'react';
import { DataContext, DataProvider } from '../../contexts/DataContext';
import SpeakerSearchBar from '../SpeakerSearchBar/SpeakerSearchBar';
import Speaker from '../Speaker/Speaker';
import { REQUEST_STATUS } from '../../reducers/request';
import withRequest from '../HOCs/withRequest';
import withSpecialMessage from '../HOCs/withSpecialMessage';
import { compose } from 'recompose';
const SpeakersComponent = ({ bgColor }) => {
const specialMessage = '';
const { speakers, status } = useContext(DataContext);
const onFavoriteToggleHandler = async (speakerRec) => {
put({
...speakerRec,
isFavorite: !speakerRec.isFavorite,
});
};
const [searchQuery, setSearchQuery] = useState('');
const success = status === REQUEST_STATUS.SUCCESS;
const isLoading = status === REQUEST_STATUS.LOADING;
const hasErrored = status === REQUEST_STATUS.ERROR;
// Rendering code unchanged
return (
<div className={bgColor}>
...
</div>
);
};
const Speakers = (props) => {
return (
<DataProvider>
<SpeakersComponent {...props}></SpeakersComponent>
</DataProvider>
);
};
export default Speakers;
Refactoring Of DatabaseContext
Rename and Add State
Now we understand the context it is time to refactor Originally we had this
import React, { createContext } from 'react';
const SpeakersContext = createContext();
const SpeakersProvider = ({ children }) => {
const speakers = [
{ imageSrc: 'speaker-component-1124', name: 'Douglas Crockford' },
{ imageSrc: 'speaker-component-1530', name: 'Tamara Baker' },
{ imageSrc: 'speaker-component-10803', name: 'Eugene Chuvyrov' },
];
return (
<SpeakersContext.Provider value={speakers}>
{children}
</SpeakersContext.Provider>
);
};
export { SpeakersContext, SpeakersProvider };
Lets move the data into a state object along with the status of the data loading. e.g. success and failure and rename to DataProvider
import React, { createContext } from 'react';
const DataContext = createContext();
const DataProvider = ({ children }) => {
const speakers = [
....
]
const state = {
speakers: speakers,
status: 'success'
}
return (
<SpeakersContext.Provider value={state}>
{children}
</SpeakersContext.Provider>
);
};
export { DataContext, DataProvider };
Add UseState and UseEffect
We do this to allow the use of UseState and we use UseEffect to allow the state to be changed. We will state to see a component which has much of it's code using hooks.
...
const [state, setState] = useState({
speakers: speakers,
status: 'loading'
})
useEffect(() => {
const timer = setTimeout(() => {
setState({
speakers: speakers,
status: 'success',
error: undefined,
})
}, 1000)
return () => clearTimeout(timer)
}, [])
return (
<SpeakersContext.Provider value={state}>
{children}
</SpeakersContext.Provider>
);
};
export { DataContext, DataProvider };
Custom Hook
Now we see the list of hooks being used we can put these in our own hook which is just a function starting with use. First here is our custom hook which is the same code just in a different file. I think it is great to put it in a different file but there seems to be no framework around this just a choice which can be open to interpretation.
import React, { useState, useEffect } from 'react';
const useRequestSimple = () => {
const speakers = [
...
];
const [state, setState] = useState({
speakers: speakers,
status: 'loading',
});
useEffect(() => {
const timer = setTimeout(() => {
setState({
speakers: speakers,
status: 'success',
error: undefined,
});
}, 1000);
return () => clearTimeout(timer);
}, []);
return state;
};
export default useRequestSimple;
And the now easy to manage DataContext
import React, { createContext } from 'react';
import useRequestSimple from '../hooks/useRequestSimple';
const DataContext = createContext();
const DataProvider = ({ children }) => {
const state = useRequestSimple();
return <DataContext.Provider value={state}>{children}</DataContext.Provider>;
};
export { DataContext, DataProvider };
Final Solution
We need to implement the REST aspect into the Context solution. Most of this involved moving the original HOC around to build a custom hook UseRequest. Here is the code at the end of refactoring
import React, { useReducer, useEffect } from 'react';
import requestReducer, { REQUEST_STATUS } from '../reducers/request';
import axios from 'axios';
import {
GET_ALL_FAILURE,
GET_ALL_SUCCESS,
PUT_FAILURE,
PUT_SUCCESS,
} from '../actions/request';
const useRequest = (baseUrl, routeName) => {
const [{ records, status, error }, dispatch] = useReducer(requestReducer, {
status: REQUEST_STATUS.LOADING,
records: [],
error: null,
});
useEffect(() => {
const fetchData = async () => {
try {
const response = await axios.get(`${baseUrl}/${routeName}`);
dispatch({
type: GET_ALL_SUCCESS,
records: response.data,
});
} catch (e) {
console.log('Loading data error', e);
dispatch({
type: GET_ALL_FAILURE,
error: e,
});
}
};
fetchData();
}, [baseUrl, routeName]);
const propsLocal = {
records,
status,
error,
put: async (record) => {
try {
await axios.put(`${baseUrl}/${routeName}/${record.id}`, record);
dispatch({
type: PUT_SUCCESS,
record: record,
});
} catch (e) {
dispatch({
type: PUT_FAILURE,
error: e,
});
}
},
};
return propsLocal;
};
export default useRequest;
From this you can see that useRequest takes the same parameters as input, baseUrl and RouteName and returns the same propsLocal, And here is the final DataContext
import React, { createContext } from 'react';
import useRequest from '../hooks/useRequest';
const DataContext = createContext();
const DataProvider = ({ children, baseUrl, routeName }) => {
const state = useRequest(baseUrl, routeName);
return <DataContext.Provider value={state}>{children}</DataContext.Provider>;
};
export { DataContext, DataProvider };
Theme Example With Context
Create a context with a customer hook useTheme
import React from 'react';
import useTheme from '../hooks/useTheme';
const THEMELIST = {
DARK: 'dark',
LIGHT: 'light',
};
const ThemeContext = React.createContext();
const ThemeProvider = ({ children, startingTheme }) => {
const state = useTheme(startingTheme);
return (
<ThemeContext.Provider value={state}>{children}</ThemeContext.Provider>
);
};
export { ThemeContext, ThemeProvider, THEMELIST };
Implement Hook useTheme
Returns the theme and the function to toggle it.
import React, { useState } from 'react';
import { THEMELIST } from '../contexts/ThemeContext';
const useTheme = (startingTheme) => {
const [theme, setTheme] = useState(startingTheme);
return {
theme,
toggleTheme: () => {
if (theme === THEMELIST.DARK) {
setTheme(THEMELIST.LIGHT);
} else {
setTheme(THEMELIST.DARK);
}
},
};
};
export default useTheme;
Implement Theme in the Layout
Read the context and set the appropriate class on the div
import React, { useContext } from 'react';
import Header from '../Header/Header';
import Menu from '../Menu/Menu';
import Footer from '../Footer/Footer';
import {
ThemeContext,
THEMELIST,
ThemeProvider,
} from '../../contexts/ThemeContext';
const LayoutComponent = ({ children }) => {
const { theme } = useContext(ThemeContext);
const classNameValue =
theme === THEMELIST.LIGHT
? 'overflow-auto bg-white'
: 'overflow-auto bg-gray-700';
return (
<div className={classNameValue}>
<div className="mx-4 my-3">
<Header />
<Menu />
{children}
<Footer />
</div>
</div>
);
};
const Layout = ({ children }) => (
<ThemeProvider startingTheme={THEMELIST.LIGHT}>
<LayoutComponent>{children}</LayoutComponent>
</ThemeProvider>
);
export default Layout;
Implement Toggle on Menu
Read the context and call the function when button pressed
import React, { useContext } from 'react';
import Link from 'next/link';
import { ThemeContext } from '../../contexts/ThemeContext';
const Menu = () => {
const { toggleTheme } = useContext(ThemeContext);
return (
<nav className="flex items-center justify-between flex-wrap bg-gray-800 mb-6 px-6 h-16 rounded-md">
<div className="w-full flex flex-grow">
<div className="flex items-center flex-grow">
<Link href="/">
<a className="block md:inline-block text-gray-300 hover:text-white mr-4">
Home
</a>
</Link>
<Link href="/speakers">
<a className="block md:inline-block text-gray-300 hover:text-white mr-4">
Speakers
</a>
</Link>
</div>
<button
onClick={() => {
toggleTheme();
}}
className="hover:text-blue-500 font-semibold py-1 px-2 rounded focus:outline-none bg-black text-white"
>
Toggle Theme
</button>
</div>
</nav>
);
};
export default Menu;
Performance And Monitoring
Profiler
This provides an overview of performance using a Flamegraph, how we rename things.
Tracking Down An Issue
Run the Profiler
What was interesting was when we toggled the button for favoured speaker the Flamegraph showed that the SpeakerImage was rendered 13 times (one for each speaker). So understanding what should be rendered is probably the important thing to know.
Adding a console statement in the rendering clearly shows that this is true.
Adding Detail
Under the Settings Cog on the Profilier you will see the following.
Select "Record why each component rendered while profiling" and it will show the answer on the next run
And the Answer Is=
So here is the answer, Props change of onFavouriteToggle
Solving the Issue
This was solved by using React.memo and React.useCallback. This does seem like a convoluted approach with a lot of onus on the developer. The full code is provided below.
- React.memo wrapper on the Speaker
import React from 'react';
import SpeakerImage from '../SpeakerImage/SpeakerImage';
import SpeakerFavoriteButton from '../SpeakerFavoriteButton/SpeakerFavoriteButton';
const Speaker = React.memo(({
id,
bio,
firstName,
lastName,
isFavorite,
put
}) => (
...
<div className="rounded overflow-hidden shadow-lg p-6 bg-white">
<div className="grid grid-cols-4 mb-6">
<div className="font-bold text-lg col-span-3">{`${firstName} ${lastName}`}</div>
<div className="flex justify-end">
<SpeakerFavoriteButton
isFavorite={isFavorite}
onFavoriteToggle={() => {
put({
id,
firstName,
lastName,
bio,
isFavorite: !isFavorite,
});
}}
/>
</div>
</div>
<div className="mb-6">
<SpeakerImage id={id} />
</div>
<div>{bio.substr(0, 70) + '...'}</div>
</div>
));
export default Speaker;
- React.useCallback wrapper for the put function (update favourite)
import React, { useReducer, useEffect } from 'react';
import requestReducer, { REQUEST_STATUS } from '../reducers/request';
import axios from 'axios';
import {
GET_ALL_FAILURE,
GET_ALL_SUCCESS,
PUT_FAILURE,
PUT_SUCCESS,
} from '../actions/request';
const useRequest = (baseUrl, routeName) => {
const [{ records, status, error }, dispatch] = useReducer(requestReducer, {
status: REQUEST_STATUS.LOADING,
records: [],
error: null,
});
useEffect(() => {
const fetchData = async () => {
try {
const response = await axios.get(`${baseUrl}/${routeName}`);
dispatch({
type: GET_ALL_SUCCESS,
records: response.data,
});
} catch (e) {
console.log('Loading data error', e);
dispatch({
type: GET_ALL_FAILURE,
error: e,
});
}
};
fetchData();
}, [baseUrl, routeName]);
const propsLocal = {
records,
status,
error,
put: React.useCallback(async (record) => {
try {
await axios.put(`${baseUrl}/${routeName}/${record.id}`, record);
dispatch({
type: PUT_SUCCESS,
record: record,
});
} catch (e) {
dispatch({
type: PUT_FAILURE,
error: e,
});
}
},[]),
};
return propsLocal;
};
export default useRequest;
Component Techniques
Managing REST Resources
When we use async function it is important to clean up the resources in the event of failure or the use navigating away from the page. Separation of the code makes this a lot easier. For our REST implementation we need to manage the axios calls. For our fix we get a cancel token and pass it into the get request. You also need to call cancel on the token on the clean up method. Here is the code.
import React, { useReducer, useEffect } from 'react';
import requestReducer, { REQUEST_STATUS } from '../reducers/request';
import axios from 'axios';
import {
GET_ALL_FAILURE,
GET_ALL_SUCCESS,
PUT_FAILURE,
PUT_SUCCESS,
} from '../actions/request';
const useRequest = (baseUrl, routeName) => {
const [{ records, status, error }, dispatch] = useReducer(requestReducer, {
status: REQUEST_STATUS.LOADING,
records: [],
error: null,
});
const signal = React.useRef(axios.CancelToken.source());
useEffect(() => {
const fetchData = async () => {
try {
const response = await await axios.get(`${baseUrl}/${routeName}`, {
cancelToken: signal.current.token,
});
dispatch({
type: GET_ALL_SUCCESS,
records: response.data,
});
} catch (e) {
console.log('Loading data error', e);
if (axios.isCancel(e)) {
console.log('Get request canceled');
} else {
dispatch({
type: GET_ALL_FAILURE,
error: e,
});
}
}
};
fetchData();
return () => {
console.log('unmount and cancel running axios request');
signal.current.cancel();
};
}, [baseUrl, routeName]);
const propsLocal = {
records,
status,
error,
put: React.useCallback(async (record) => {
try {
await axios.put(`${baseUrl}/${routeName}/${record.id}`, record);
dispatch({
type: PUT_SUCCESS,
record: record,
});
} catch (e) {
dispatch({
type: PUT_FAILURE,
error: e,
});
}
},[]),
};
return propsLocal;
};
export default useRequest;