Where to put chained actions in React/Redux - reactjs

Fairly simple use case: I have actions/events that will cause an ajax request to be executed and then update a list.
The problem is I'm not sure how (specifically, where to kick off a request for a new list when the page is changed.
redux store
const defaultStore = {
list: [],
page: 1
};
Wrapper Component
const wrapper = props => (
<div>
<List {...props}> {/* This list should have page controls */}
<PageControls {...props} />
</List>
<List /> {/* This list should not have page controls */}
</div>
);
List component
const List = props => (
<div>
{props.children} {/* render page controls if present */}
{props.items.map((item, k) => <div key={k}>item</div>
</div>
);
Pager Controls component
const PageControls = props => (
<div>
<span onClick={props.changePage(-1)}>Backward</span>
<span onClick={props.changePage(1)}>Forward</span>
</div>
);
actionCreator
function changePage(delta) {
return {
type: 'CHANGE_PAGE',
delta
};
}
// utilizes react-thunk middleware
function getList(page = 1) {
return dispatch =>
axios.get(`/path/to/api?page=${page}`)
.then(res => dispatch(updateList(res.data));
}
function updateList(newList) {
return {
type: 'UPDATE_LIST',
newList
};
}
reducer
function reducer(state = {}, action) {
switch(action.type) {
case 'CHANGE_PAGE':
return {...state, page: state.page + action.delta};
case 'UPDATE_LIST':
return {...state, list: action.newList};
default:
return state;
}
}
At this point I could do a couple of things -- I could make every actionCreator that should trigger a list update dispatch that action:
function changePage(delta) {
return dispatch => {
dispatch({
type: 'CHANGE_PAGE',
delta
});
return dispatch(getList(store.getState() + delta));
}
}
But this seems messy. Now not only do I have to get my store but I also have to turn every actionCreator that affects the list into a thunk.
The only other thing I can think of is to have my <List> component use store.subscribe somewhere to watch for changes to page and then kick off another getList action, but this also seems like I'm moving the understanding of what does and does not trigger state changes out of Redux and into my React components.
Any ideas?

Well, maybe you should change your approach. I don't see a reason to make two actions for changing page and retrieving the list. You can just dispatch getPage() action on button click, passing next page number. This should retrieve list of items and refresh your page.
In your store you should keep track on current page, so each time page refreshes the value of getPage() argument will also update.
For example (assuming that current page is not retrieved from API):
function getPage(page = 1) {
return dispatch =>
axios.get(`/path/to/api?page=${page}`)
.then(res => dispatch(updatePage(res.data, page));
}
function updatePage(newList, currentPage) {
return {
type: 'UPDATE_PAGE',
newList,
currentPage,
};
}
and connect required components to the store, in your case it would be List and PageControls components.
const PageControls = props => (
<div>
<span onClick={props.getPage(props.currentPage - 1)}>Backward</span>
<span onClick={props.getPage(props.currentPage + 1)}>Forward</span>
</div>
);
This will allow you to maintain simple and clean code. Also you can trigger it from multiple, not related components.

Related

ReactJS - Alternative to passing useState between files

Background
I have a file that presents my main page Dash.js
This presents some data from an API on a "card", from two other files List.js and ListLoading.js
I have an additional "card" which I can trigger open with default useState value of 1, and the onClick works to close, as you will see in the dash.js file.
Current Code
//Dash.js
function Dash(props) {
//control additional card
const [openCard, setopenCard] = React.useState(0);
const closeCard = () => {
setopenCard(0);
}
//set API repo
const apiUrl = (`http://example.com/api/`);
axios.get(apiUrl, {
withCredentials: true }).then((res) =>{
setAppState({ loading: false, repos: res.data.emails });
});
return (
{(openCard>0 &&
<Card>
<Cardheader onClick={() => closeCard()}>
Click here to close
</Cardheader>
<Cardbody>
Some data here
</Cardbody>
</Card>
)
|| null
}
<Card>
<ListLoading isLoading={appState.loading} repost={appState.repos} />
<Card>
);
}
//List.js
const List = (props) => {
const { repos } = props;
if (!repos || repos.length === 0) return <p>No data available</p>;
for (var key in repos) {
return (
{repos.map((repo) => {
return (
<p className='repo-text max-width' >ID:{repo.id}{" "}Value:{repo.value} </p>
);}
)}
);}
};
export default List;
//ListLoading.js
function WithListLoading(Component) {
return function WihLoadingComponent({ isLoading, ...props }) {
if (!isLoading) return <Component {...props} />;
return (
<p style={{ textAlign: 'center', fontSize: '30px' }}>
Fetching data may take some time, please wait
</p>
);
};
}
export default WithListLoading;
Desired Outcome
I want to set the the value for openCard.useState() to the repos.id.
e.g. onClick={() => openCard({repos.id})}
The complication of this is that I need to retrieve that code from List.js and pass it to the useState for the openCard, which is in Dash.js.
I am still fairly new to react so this is proving a little tricky to work out how to do.
What I've tried
I have looked into useContext, but either it has confused me or I am right to think this would not work for what I am trying to do.
I have looked into redux, however this seems like that may be overkill for this solution.
I have tried a series of passing the different constants via import/export however I now understand that useState is not designed to work this way and should really be used within the function/class where it is contained.
So any thoughts to remedy would be greatly appreciated!
So, just to restate what I understood your issue to be:
You have a parent component that renders a list of objects and can render a detail card of one of the object.
You want to have a single item in your list of objects be able to tell the parent "please open card 123".
Now to look at the options you considered:
Redux I agree Redux is overkill for this. Redux is usually only necessary if you need complex, possibly async reading and writing to a single shared datasource across the whole scope of your application. For a little UI interaction like this, it is definitely not worth setting up Redux.
React Context Context relies on a Provider component, which you wrap some chunk of your app in. Any component below that Provider can then use useContext to reach into the memory of that Provider. You can store anything in there that you could store in a component, from a single state variable up to a more complex useReducer setup. So, in a way, this basically does what you were hoping to do with static variables passing the state around. This is the right solution if you were going to be using this state value across a wide variety of components.
Props are probably the right way to go here - since you have a parent who wants to get messages from a child directly you can give the child a callback function. This is the same as the onClick function you can give a button, except here you can pass your list a onShowCard function.
In your Dash:
<ListLoading
isLoading={appState.loading} repost={appState.repos}
onShowCard={(cardId) => setopenCard(cardId)} />
At the end of the List:
{repos.map((repo) => {
return (
<button key={repo.id} className='repo-text max-width' onClick={() => { props.onShowCard(repo.id) }>
ID:{repo.id}{" "}Value:{repo.value}
</button>
);}
)}
You can pass on the function to update state to ListLoading component which will be forwarded to List component assuming it is wrapped by thee HOC WithListLoading.
Inside List you can then attach and onClick on the element to pass on the id of the clicked element
function Dash(props) {
//control additional card
const [openCard, setopenCard] = React.useState(0);
const closeCard = () => {
setopenCard(0);
}
//set API repo
const apiUrl = (`http://example.com/api/`);
axios.get(apiUrl, {
withCredentials: true
}).then((res) =>{
setAppState({ loading: false, repos: res.data.emails });
});
const handleOpen = id => {
setopenCard(id);
}
return (
{(openCard>0 &&
<Card>
<Cardheader onClick={() => closeCard()}>
Click here to close
</Cardheader>
<Cardbody>
Some data here
</Cardbody>
</Card>
)
|| null
}
<Card>
<ListLoading isLoading={appState.loading} repost={appState.repos} handleOpen={handleOpen} />
<Card>
);
}
const List = (props) => {
const { repos, handleOpen } = props;
if (!repos || repos.length === 0) return <p>No data available</p>;
for (var key in repos) {
return (
{repos.map((repo) => {
return (
<p className='repo-text max-width' onClick={() => props.handleOpen(repo.id)} >ID:{repo.id}{" "}Value:{repo.value} </p>
);}
)}
);}
};
export default List;

How to prevent unnecessary re-renders with React Hooks, function components and function depending on item list

List of items to render
Given a list of items (coming from the server):
const itemsFromServer = {
"1": {
id: "1",
value: "test"
},
"2": {
id: "2",
value: "another row"
}
};
Function component for each item
We want to render each item, but only when necessary and something changes:
const Item = React.memo(function Item({ id, value, onChange, onSave }) {
console.log("render", id);
return (
<li>
<input
value={value}
onChange={event => onChange(id, event.target.value)}
/>
<button onClick={() => onSave(id)}>Save</button>
</li>
);
});
ItemList function component with a handleSave function that needs to be memoized.
And there is a possibility to save each individual item:
function ItemList() {
const [items, setItems] = useState(itemsFromServer);
const handleChange = useCallback(
function handleChange(id, value) {
setItems(currentItems => {
return {
...currentItems,
[id]: {
...currentItems[id],
value
}
};
});
},
[setItems]
);
async function handleSave(id) {
const item = items[id];
if (item.value.length < 5) {
alert("Incorrect length.");
return;
}
await save(item);
alert("Save done :)");
}
return (
<ul>
{Object.values(items).map(item => (
<Item
key={item.id}
id={item.id}
value={item.value}
onChange={handleChange}
onSave={handleSave}
/>
))}
</ul>
);
}
How to prevent unnecessary re-renders of each Item when only one item changes?
Currently on each render a new handleSave function is created. When using useCallback the items object is included in the dependency list.
Possible solutions
Pass value as parameter to handleSave, thus removing the items object from the dependency list of handleSave. In this example that would be a decent solution, but for multiple reasons it's not preferred in the real life scenario (eg. lots more parameters etc.).
Use a separate component ItemWrapper where the handleSave function can be memoized.
function ItemWrapper({ item, onChange, onSave }) {
const memoizedOnSave = useCallback(onSave, [item]);
return (
<Item
id={item.id}
value={item.value}
onChange={onChange}
onSave={memoizedOnSave}
/>
);
}
With the useRef() hook, on each change to items write it to the ref and read items from the ref inside the handleSave function.
Keep a variable idToSave in the state. Set this on save. Then trigger the save function with useEffect(() => { /* save */ }, [idToSave]). "Reactively".
Question
All of the solutions above seem not ideal to me. Are there any other ways to prevent creating a new handleSave function on each render for each Item, thus preventing unnecessary re-renders? If not, is there a preferred way to do this?
CodeSandbox: https://codesandbox.io/s/wonderful-tesla-9wcph?file=/src/App.js
The first question I'd like to ask : is it really a problem to re-render ?
You are right that react will re-call every render for every function you have here, but your DOM should not change that much it might not be a big deal.
If you have heavy calculation while rendering Item, then you can memoize the heavy calculations.
If you really want to optimize this code, I see different solutions here:
Simplest solution : change the ItemList to a class component, this way handleSave will be an instance method.
Use an external form library that should work fine: you have powerfull form libraries in final-form, formik or react-hook-form
Another external library : you can try recoiljs that has been build for this specific use-case
Wow this was fun! Hooks are very different then classes. I got it to work by changing your Item component.
const Item = React.memo(
function Item({ id, value, onChange, onSave }) {
console.log("render", id);
return (
<li>
<input
value={value}
onChange={event => onChange(id, event.target.value)}
/>
<button onClick={() => onSave(id)}>Save</button>
</li>
);
},
(prevProps, nextProps) => {
// console.log("PrevProps", prevProps);
// console.log("NextProps", nextProps);
return prevProps.value === nextProps.value;
}
);
By adding the second parameter to React.memo it only updates when the value prop changes. The docs here explain that this is the equivalent of shouldComponentUpdate in classes.
I am not an expert at Hooks so anyone who can confirm or deny my logic, please chime in and let me know but I think that the reason this needs to be done is because the two functions declared in the body of the ItemList component (handleChange and handleSave) are in fact changing on each render. So when the map is happening, it passes in new instances each time for handleChange and handleSave. The Item component detects them as changes and causes a render. By passing the second parameter you can control what the Item component is testing and only check for the value prop being different and ignore the onChange and onSave.
There might be a better Hooks way to do this but I am not sure how. I updated the code sample so you can see it working.
https://codesandbox.io/s/keen-roentgen-5f25f?file=/src/App.js
I've gained some new insights (thanks Dan), and I think I prefer something like this below. Sure it might look a bit complicated for such a simple hello world example, but for real world examples it might be a good fit.
Main changes:
Use a reducer + dispatch for keeping state. Not required, but to make it complete. Then we don't need useCallback for the onChange handler.
Pass down dispatch via context. Not required, but to make it complete. Otherwise just pass down dispatch.
Use an ItemWrapper (or Container) component. Adds an additional component to the tree, but provides value as the structure grows. It also reflects the situation we have: each item has a save functionality that requires the entire item. But the Item component itself does not. ItemWrapper might be seen as something like a save() provider in this scenario ItemWithSave.
To reflect a more real world scenario there is now also a "item is saving" state and the other id that's only used in the save() function.
The final code (also see: https://codesandbox.io/s/autumn-shape-k66wy?file=/src/App.js).
Intial state, items from server
const itemsFromServer = {
"1": {
id: "1",
otherIdForSavingOnly: "1-1",
value: "test",
isSaving: false
},
"2": {
id: "2",
otherIdForSavingOnly: "2-2",
value: "another row",
isSaving: false
}
};
A reducer to manage state
function reducer(currentItems, action) {
switch (action.type) {
case "SET_VALUE":
return {
...currentItems,
[action.id]: {
...currentItems[action.id],
value: action.value
}
};
case "START_SAVE":
return {
...currentItems,
[action.id]: {
...currentItems[action.id],
isSaving: true
}
};
case "STOP_SAVE":
return {
...currentItems,
[action.id]: {
...currentItems[action.id],
isSaving: false
}
};
default:
throw new Error();
}
}
Our ItemList to render all items from the server
export default function ItemList() {
const [items, dispatch] = useReducer(reducer, itemsFromServer);
return (
<ItemListDispatch.Provider value={dispatch}>
<ul>
{Object.values(items).map(item => (
<ItemWrapper key={item.id} item={item} />
))}
</ul>
</ItemListDispatch.Provider>
);
}
The main solution ItemWrapper or ItemWithSave
function ItemWrapper({ item }) {
const dispatch = useContext(ItemListDispatch);
const handleSave = useCallback(
// Could be extracted entirely
async function save() {
if (item.value.length < 5) {
alert("Incorrect length.");
return;
}
dispatch({ type: "START_SAVE", id: item.id });
// Save to API
// eg. this will use otherId that's not necessary for the Item component
await new Promise(resolve => setTimeout(resolve, 1000));
dispatch({ type: "STOP_SAVE", id: item.id });
},
[item, dispatch]
);
return (
<Item
id={item.id}
value={item.value}
isSaving={item.isSaving}
onSave={handleSave}
/>
);
}
Our Item
const Item = React.memo(function Item({ id, value, isSaving, onSave }) {
const dispatch = useContext(ItemListDispatch);
console.log("render", id);
if (isSaving) {
return <li>Saving...</li>;
}
function onChange(event) {
dispatch({ type: "SET_VALUE", id, value: event.target.value });
}
return (
<li>
<input value={value} onChange={onChange} />
<button onClick={onSave}>Save</button>
</li>
);
});

Post Component re-renders even other instance of Post Component is called

I am new to web development and is trying to learn react and redux.
I am following this tutorial https://www.youtube.com/playlist?list=PLC3y8-rFHvwheJHvseC3I0HuYI2f46oAK
As I'm trying to extend what I learned,
I'm trying to list all the users (clickable),
once clicked will display (expand/collapse) all the post of the selected user (clickable again),
once post is clicked, will display (expand/collapse) all the comment on that selected post
APIs to use:
users: https://jsonplaceholder.typicode.com/users
posts: https://jsonplaceholder.typicode.com/posts?userId={user.id}
comments: https://jsonplaceholder.typicode.com/comments?postId={post.id}
Right now, I was able to list all the users and able to do the expand/collapse,
and also able to display the post of the user however, I am experiencing below problem:
If I click on user[0] it will expand and display the post of user[0] (OK).
then if I click user[1], will expand and display the post of user[1] (OK)
however upon click of user[1] it also changes the post listed on user[0] to list the post of user[1] as well (NOT OK)
here is my UserContainer.js
import React, { useEffect } from "react";
import { connect } from "react-redux";
import { fetchUsers, updateUser } from "../redux";
import PostsContainer from "./PostsContainer";
function UsersContainer({ userData, fetchUsers, updateUser }) {
useEffect(() => {
fetchUsers();
}, []);
const handleClick = event => {
//console.log(userData.users)
const indx = userData.users.findIndex(obj => obj.id == event.target.value);
//console.log(indx)
userData.users[indx].collapse = !userData.users[indx].collapse;
//console.log(userData.users[indx].collapse + " " + indx);
updateUser(userData);
};
return userData.loading ? (
<h2>loading</h2>
) : userData.error ? (
<h2>{userData.error}</h2>
) : (
<div>
<h2>User List</h2>
<div className="list-group">
{userData.users.map(user => (
<div>
<button
type="button"
className="list-group-item list-group-item-action"
key={user.id}
onClick={handleClick}
value={user.id}
>
{user.name}
</button>
{/* for update to change SampleContainer component to POST component */}
{!user.collapse && (
//<SampleContainer id={user.id} name={user.name} />
<PostsContainer id={user.id} />
)}
</div>
))}
</div>
</div>
);
}
const mapStateToProps = state => {
return {
userData: state.user
};
};
const mapDispatchToProps = dispatch => {
return {
fetchUsers: () => dispatch(fetchUsers()),
updateUser: users => dispatch(updateUser(users))
};
};
export default connect(
mapStateToProps,
mapDispatchToProps
)(UsersContainer);
I don't know why stackoverflow finds my post have code that doesn't properly formatted therefore I wasn't able to put the PostContainer component.
here is the codesandbox link for complete reference of the code:
https://codesandbox.io/s/react-redux-testing-mi6ms
You are storing the posts of that particular selected user at an instance, so change the state posts of postsReducer to object to store the posts of multiple users
Please find the code sandbox here
EDIT
If you want to prevent the loading indicator for other users then, you need to store the array of ids that are currently being loaded, and remove id once the data is loaded, for that you need to update the way you are dealing with loading state of reducer from boolean to array
Please find the updated sandbox here

Accessing Apollo's loading boolean outside of Mutation component

The Mutation component in react-apollo exposes a handy loading boolean in the render prop function which is ideal for adding loaders to the UI whilst a request is being made. In the example below my Button component calls the createPlan function when clicked which initiates a GraphQL mutation. Whilst this is happening a spinner appears on the button courtesy of the loading prop.
<Mutation mutation={CREATE_PLAN}>
{(createPlan, { loading }) => (
<Button
onClick={() => createPlan({ variables: { input: {} } })}
loading={loading}
>
Save
</Button>
)}
</Mutation>
The issue I have is that other aspects of my UI also need to change based on this loading boolean. I have tried lifting the Mutation component up the React tree so that I can manually pass the loading prop down to any components which rely on it, which works, but the page I am building has multiple mutations that can take place at any given time (such as deleting a plan, adding a single item in a plan, deleting a single item in a plan etc.) and having all of these Mutation components sitting at the page-level component feels very messy.
Is there a way that I can access the loading property outside of this Mutation component? If not, what is the best way to handle this problem? I have read that you can manually update the Apollo local state using the update function on the Mutation component (see example below) but I haven't been able to work out how to access the loading value here (plus it feels like accessing the loading property of a specific mutation without having to manually write it to the cache yourself would be a common request).
<Mutation
mutation={CREATE_PLAN}
update={cache => {
cache.writeData({
data: {
createPlanLoading: `I DON"T HAVE ACCESS TO THE LOADING BOOLEAN HERE`,
},
});
}}
>
{(createPlan, { loading }) => (
<Button
onClick={() => createPlan({ variables: { input: {} } })}
loading={loading}
>
Save
</Button>
)}
</Mutation>
I face the same problem in my projects and yes, putting all mutations components at the page-level component is very messy. The best way I found to handle this is by creating React states. For instance:
const [createPlanLoading, setCreatePLanLoading] = React.useState(false);
...
<Mutation mutation={CREATE_PLAN} onCompleted={() => setCreatePLanLoading(false)}>
{(createPlan, { loading }) => (
<Button
onClick={() => {
createPlan({ variables: { input: {} } });
setCreatePLanLoading(true);
}
loading={loading}
>
Save
</Button>
)}
</Mutation>
I like the answer with React States. However, when there are many different children it looks messy with so many variables.
I've made a bit update for it for these cases:
const Parent = () => {
const [loadingChilds, setLoading] = useState({});
// check if at least one child item is loading, then show spinner
const loading = Object.values(loadingChilds).reduce((t, value) => t || value, false);
return (
<div>
{loading ? (
<CircularProgress />
) : null}
<Child1 setLoading={setLoading}/>
<Child2 setLoading={setLoading}/>
</div>
);
};
const Child1 = ({ setLoading }) => {
const [send, { loading }] = useMutation(MUTATION_NAME);
useEffect(() => {
// add info about state to the state object if it's changed
setLoading((prev) => (prev.Child1 !== loading ? { ...prev, Child1: loading } : prev));
});
const someActionHandler = (variables) => {
send({ variables});
};
return (
<div>
Child 1 Content
</div>
);
};
const Child2 = ({ setLoading }) => {
const [send, { loading }] = useMutation(MUTATION_NAME2);
useEffect(() => {
// add info about state to the state object if it's changed
setLoading((prev) => (prev.Child2 !== loading ? { ...prev, Child2: loading } : prev));
});
const someActionHandler = (variables) => {
send({ variables});
};
return (
<div>
Child 2 Content
</div>
);
};

How to handle Audio playing in React & Redux

I am making an audio player. It has pause, rewind and time seek features. How and who should handle the audio element?
I can put it aside the store. I cant put it directly on the state, since it might get cloned. Then when in the reducer I can interact with it. The problem is that if I need to sync the time slider with the audio I will need to constantly poll the store using an action. It also doesn't really makes sense semantically speaking.
I can create a custom React component, Audio, which does everything I said. the problem isn't solved. How can I refresh the slider? I could poll but I really don't like this solution. Besides, unless I create a component that contains both audio and slider, I would still need to use redux to connect both.
So what is the most redux way to handle audio with progress display?
Redux - it's all about the state and consistency.
Your goal is to keep in sync the song time and the progress bar.
I see two possible aproaches:
1. Keep everyting in the Store.
So you have to keep the song's current time (in seconds for instance) in the Store, because of there are a few dependend components and its hard to sync them without the Store.
You have few events those change the current time:
The current time itself. For example on each 1 second.
Rewind.
Time seek.
On a time change you will dispatch an action and will update the Store with the new time. Thereby keeping current song's time all components will be in sync.
Managing the state in unidirectional data flow with actions dispatching, reducers and stores is the Redux way of implementing any component.
Here is a pseudo code of the #1 aproach:
class AudioPlayer extends React.Component {
onPlay(second) {
// Store song current time in the Store on each one second
store.dispatch({ type: 'SET_CURRENT_SECOND', second });
}
onRewind(seconds) {
// Rewind song current time
store.dispatch({ type: 'REWIND_CURRENT_SECOND', seconds });
}
onSeek(seconds) {
// Seek song current time
store.dispatch({ type: 'SEEK_CURRENT_SECOND', seconds });
}
render() {
const { currentTime, songLength } = this.state;
return <div>
<audio onPlay={this.onPlay} onRewind={this.onRewind} onSeek={this.onSeek} />
<AudioProgressBar currentTime songLength />
</div>
}
}
2. Keep as less as possible in the Store.
If the above aproach doesn't fit your needs, for example you may have a lot of Audio players on a same screen - there may be a performance gap.
In that case you can access your HTML5 audio tag and components via refs in the componentDidMount lifecycle method.
The HTML5 audio tag has DOM events and you can keep the both components in sync without touching the Store. If there is a need to save something in the Store - you can do it anytime.
Please take a look at react-audio-player source code and check how it handles the refs and what API the plugin exposes. For sure you can take inspiration from there. Also you can reuse it for your use case.
Here are some of the API methods those are related to your questions:
onSeeked - Called when the user drags the time indicator to a new time. Passed the event.
onPlay - Called when the user taps play. Passed the event.
onPause - Called when the user pauses playback. Passed the event.
onListen - Called every listenInterval milliseconds during playback. Passed the event.
What approach should I use?
It depends to your use case specifics. However generally speaking in the both aproaches it's a good idea to implement a presentational component with the necessary API methods and it's up to you to decide how much data to manage in the Store.
So I created a starting component for you to illustrate how to handle the refs to the audio and slider. Start / Stop / Seeking features included. For sure it has drawbacks, but as I already mentioned it's a good starting point.
You can evolve it to a presentational component with good API methods, that suits your needs.
class Audio extends React.Component {
constructor(props) {
super(props);
this.state = {
duration: null
}
};
handlePlay() {
this.audio.play();
}
handleStop() {
this.audio.currentTime = 0;
this.slider.value = 0;
this.audio.pause();
}
componentDidMount() {
this.slider.value = 0;
this.currentTimeInterval = null;
// Get duration of the song and set it as max slider value
this.audio.onloadedmetadata = function() {
this.setState({duration: this.audio.duration});
}.bind(this);
// Sync slider position with song current time
this.audio.onplay = () => {
this.currentTimeInterval = setInterval( () => {
this.slider.value = this.audio.currentTime;
}, 500);
};
this.audio.onpause = () => {
clearInterval(this.currentTimeInterval);
};
// Seek functionality
this.slider.onchange = (e) => {
clearInterval(this.currentTimeInterval);
this.audio.currentTime = e.target.value;
};
}
render() {
const src = "https://mp3.gisher.org/download/1000/preview/true";
return <div>
<audio ref={(audio) => { this.audio = audio }} src={src} />
<input type="button" value="Play"
onClick={ this.handlePlay.bind(this) } />
<input type="button"
value="Stop"
onClick={ this.handleStop.bind(this) } />
<p><input ref={(slider) => { this.slider = slider }}
type="range"
name="points"
min="0" max={this.state.duration} /> </p>
</div>
}
}
ReactDOM.render(<Audio />, document.getElementById('container'));
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.1.0/react.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.1.0/react-dom.min.js"></script>
<div id="container">
<!-- This element's contents will be replaced with your component. -->
</div>
If you have any questions feel free to comment below! :)
Simple React solution
In order to keep audio player in sync with rest of the app you can use classic React props down events up data flow. That means you keep your state in parent component and pass it to children components as props, along with event handlers that modify the state. More specifically, in your state you can have:
seekTime: this will be used to force time update on your player
appTime: this will be broadcasted by your player and passed to other components in order to keep them in sync with player.
An example implementation might look like this:
import { useRef, useEffect, useState } from "react";
function App() {
// we'll define state in parent component:
const [playing, setPlaying] = useState(false);
const [duration, setDuration] = useState(0);
const [seekTime, setSeekTime] = useState(0); // forces player to update its time
const [appTime, setAppTime] = useState(0); // dictated by player, broadcasted to other components
// state will be passed as props and setter function will allow child components to change state:
return (
<div>
<button onClick={() => setPlaying(true)}>PLAY</button>
<button onClick={() => setPlaying(false)}>PAUSE</button>
<button onClick={() => setSeekTime(appTime - 5)}>-5 SEC</button>
<button onClick={() => setSeekTime(appTime + 5)}>+5 SEC</button>
<Seekbar
value={appTime}
min="0"
max={duration}
onInput={(event) => setSeekTime(event.target.value)}
/>
<Player
playing={playing}
seekTime={seekTime}
onTimeUpdate={(event) => setAppTime(event.target.currentTime)}
onLoadedData={(event) => setDuration(event.target.duration)}
/>
</div>
);
}
function Seekbar({ value, min, max, onInput }) {
return (
<input
type="range"
step="any"
value={value}
min={min}
max={max}
onInput={onInput}
/>
);
}
function Player({ playing, seekTime, onTimeUpdate, onLoadedData }) {
const ref = useRef(null);
if (ref.current) playing ? ref.current.play() : ref.current.pause();
//updates audio element only on seekTime change (and not on each rerender):
useEffect(() => (ref.current.currentTime = seekTime), [seekTime]);
return (
<audio
src="./your.file"
ref={ref}
onTimeUpdate={onTimeUpdate}
onLoadedData={onLoadedData}
/>
);
}
export default App;
Redux-like solution
If you prefer a redux-like solution, you can move your app's state to a reducer function and rewrite the parent component like this:
import { useRef, useEffect, useReducer } from "react";
// define reducer and initial state outside of component
const initialState = { playing: false, duration: 0, seekTime: 0, appTime: 0 };
function reducer(state, action) {
switch (action.type) {
case "play":
return { ...state, playing: true };
case "pause":
return { ...state, playing: false };
case "set-duration":
return { ...state, duration: action.value };
case "set-time":
return { ...state, appTime: action.value };
case "seek":
return { ...state, seekTime: action.value };
default:
throw new Error("Unhandled action " + action.type);
}
}
function App() {
// use reducer and dispatch instead of state
const [state, dispatch] = useReducer(reducer, initialState);
return (
<div>
<button onClick={() => dispatch({ type: "play" })}>PLAY</button>
<button onClick={() => dispatch({ type: "pause" })}>PAUSE</button>
<button
onClick={() => dispatch({ type: "seek", value: state.appTime + 5 })}
>
-5 SEC
</button>
<button
onClick={() => dispatch({ type: "seek", value: state.appTime + 5 })}
>
+5 SEC
</button>
<Seekbar
value={state.appTime}
min="0"
max={state.duration}
onInput={(event) =>
dispatch({ type: "seek", value: event.target.value })
}
/>
<Player
playing={state.playing}
seekTime={state.seekTime}
onTimeUpdate={(event) =>
dispatch({ type: "set-time", value: event.target.currentTime })
}
onLoadedData={(event) =>
dispatch({ type: "set-duration", value: event.target.duration })
}
/>
</div>
);
}
// The rest of app doesn't need any change compared to previous example.
// That's due to decoupled architecture!
function Seekbar({ value, min, max, onInput }) {
return (
<input
type="range"
step="any"
value={value}
min={min}
max={max}
onInput={onInput}
/>
);
}
function Player({ playing, seekTime, onTimeUpdate, onLoadedData }) {
const ref = useRef(null);
if (ref.current) playing ? ref.current.play() : ref.current.pause();
useEffect(() => (ref.current.currentTime = seekTime), [seekTime]);
return (
<audio
src="./your.file"
ref={ref}
onTimeUpdate={onTimeUpdate}
onLoadedData={onLoadedData}
/>
);
}
export default App;
Passing DOM ref around the app?
It might be tempting to simply pass a ref to DOM audio element around the app instead of implementing proper state management. However this solution would couple your components together and thus make your app harder to maintain. So unless you really really need the 3 miliseconds taken by React's virtual DOM (and in most cases you don't), I would advise against doing it.

Resources