I have a hook called useQueryEvents
that 1) fetches all past transactions for a user and 2) listens to the network for incoming/outgoing transactions. In both cases the transactions are passed into a function addActionToActivity
that simply appends it to the activity array and updates it in the context state under the key activity
.
I can't get the activity to sync correctly. Whenever the state updates it does not have the last transaction because it's always one step behind. If I add activity
to the dependancy it works but then starts a new listener (due to the whole function being called again with the new activity
value) which causes an infinity-like-loop which keeps switching up the state.
function useQueryEvents() {
const { state: { connectedNetwork, selectedWallet, activity },
} = useContext(LocalContext);
useEffect(() => {
async function bootstrapQueryEvents() {
// First get all the past transactions
const transactions = await getAllPastTransactions();
const contract = await getContract();
// Now save them to context state via addActionToActivity
await addActionToActivity(transactions, activity);
// Now that all the past transactions have been saved
// listen to all incoming/outgoing transactions and
// save to context state via addActionToActivity
contract.on('Transfer', async (from, to, amount, event) => {
console.log(`${from} sent ${ethers.utils.formatEther(amount)} to ${to}`);
const transaction = await formatEventToTransaction(event);
await addActionToActivity(transaction, activity);
});
}
bootstrapQueryEvents();
}, [selectedAsset, connectedNetwork, selectedWallet]); // <- I've tried adding `activity` here
}
Any ideas how I can approach updating the state while having access to the updated activity
value inside the listener without starting a new instance of the listener? Or maybe there's a different approach I can take altogether?
Thanks in advance
It's ok to use setState in useEffect you just need to have attention as described already to not create a loop. The reason why this happen in this example it's because both useEffects run in the same react cycle when you change both prop.
We moved the useEffect hook above the condition that might return a value. This solves the error because we have to ensure that React hooks are called in the same order each time a component renders. This means that we aren't allowed to use hooks inside loops, conditions or nested functions.
In real-world useEffect can contain some functionality that we don't want to be repeated if its dependencies do not change. Solution: We can memoize the data object using the useMemo hook so that rendering of the component won't create a new data object and hence useEffect will not call its body.
You can add the state variables you want to track to the hook's dependencies array and the logic in your useEffect hook will run every time the state variables change. Copied! The second parameter we passed to the useEffect hook is an array of dependencies.
If you’re using React hooks in a component with an event listener, your event listener callback cannot access the latest state. We can incorporate useRef to solve this problem. In this simple example, we are trying to access our state on a double-click event:
Use the useEffect hook to listen for state changes in React. You can add the state variables you want to track to the hook's dependencies array and the logic in your useEffect hook will run every time the state variables change. Copied! The second parameter we passed to the useEffect hook is an array of dependencies.
If you try this out you will see the listener only has access to the initial state, so it will always log: state in handler: 0 As explained in this Stack Overflow post, it’s because the listener belongs to the initial render and is not updated on subsequent rerenders. So what can we do?
You could solve this by splitting your logic into two useEffects. Right now you do two things:
The issue is that you cannot run this hook again without doing both things at the same time. And as you stated, the activity object is not what you intend it to be, as the activity object is what is passed at the time the event listener is setup.
Splitting it into two hooks could look something like this:
function useQueryEvents() {
const { state: { connectedNetwork, selectedWallet, activity },
} = useContext(LocalContext);
const [contract, setContract] = React.useState()
// Fetch transactions and setup contract
useEffect(() => {
async function fetchTransactionsAndContract() {
const transactions = await getAllPastTransactions();
const contract = await getContract();
await addActionToActivity(transactions, activity);
setContract(contract)
}
}, [])
// Once the contract is set in state, attach the event listener.
useEffect(() => {
if (contract) {
const handleTransfer = async (from, to, amount, event) => {
console.log(`${from} sent ${ethers.utils.formatEther(amount)} to ${to}`);
const transaction = await formatEventToTransaction(event);
await addActionToActivity(transaction, activity);
}
contract.on('Transfer', handleTransfer);
// Remove event listener, I imagine it will be something like
return () => {
contract.off('Transfer', handleTransfer)
}
}
// Add contract and activity to the dependencies array.
}, [contract, activity, selectedAsset, connectedNetwork, selectedWallet]);
}
I'd also like to point out that it's perfectly fine to remove and reattach event listeners.
I assume you want to add new transactions to the activity
object. I assume also you call setState
somewhere in addActionToActivity
with the new state (current activity with new transactions). You need to have access to the latest activity, but in your closure it's not the latest one.
Use setState
and pass a function to it, which will receive the current state:
setState(prevState => {
// add transactions to prevState.activity
return { ...prevState, activity: {...prevState.activity, transactions: ... }};
});
So, in your example:
function useQueryEvents() {
const { state: { connectedNetwork, selectedWallet },
} = useContext(LocalContext);
useEffect(() => {
async function bootstrapQueryEvents() {
// First get all the past transactions
const transactions = await getAllPastTransactions();
const contract = await getContract();
// Now save them to context state via addActionToActivity
await addActionToActivity(transactions);
// Now that all the past transactions have been saved
// listen to all incoming/outgoing transactions and
// save to context state via addActionToActivity
contract.on('Transfer', async (from, to, amount, event) => {
console.log(`${from} sent ${ethers.utils.formatEther(amount)} to ${to}`);
const transaction = await formatEventToTransaction(event);
await addActionToActivity(transaction);
});
}
bootstrapQueryEvents();
}, [selectedAsset, connectedNetwork, selectedWallet]); // <- I've tried adding `activity` here
}
...
const addActionToActivity = (transactions) => {
...
setState(prevState => {
// add transactions to prevState.activity
return { ...prevState, activity: {...prevState.activity, transactions: ... }};
});
}
Use useRef()
to keep and update activities, and useState()
to manage re-renders; i.e.
function useQueryEvents() {
const [epoch, setEpoch] = useState(0);
const { current: heap } = useRef({ activity: [], epoch });
useEffect(() => {
// Here you can setup your listener, and operate on
// heap.activity, which will always have an up-to-date
// list. Also, "heap" is guaranteed to be the same object
// in each render, thus even it is included into dependencies
// of useEffect() (e.g. to keep ESLint's Rules of Hooks happy,
// the useEffect() still fires just once. "setEpoch" is also
// stable across re-renders, as all state setters.
// And whenever you decide to re-render the component you do:
setEpoch(++heap.epoch);
// Or you can do something smarter, e.g. dumping to the local
// state the actual stuff you want to render in the text pass.
}, [heap, setEpoch]);
return (
// whatever you need to render
);
}
Your subscription code seems OK
The problem is that component doesn't update when activity changes
Component will not be updated automatically when something is changed inside context object. Component will update only when context object replaced with other one.
This is wrapper which returns context object with .notify()
implementation.
const UpdatableContext = ({ children }) => {
// real value
const [contextValue, setContextValue] = useState({});
// add notify function
// also cache result to avoid unnecessary component tree updates
const mutableContextValue = useMemo(() => {
let mutableContext = {
...contextValue,
notify() {
setContextValue(...value);
},
}
return mutableContext;
}, [contextValue]);
return <Context.Provider value={mutableContextValue}>
{children}
</Context.Provider>;
};
Update anything you want within such context and then call .notify()
to trigger update of all dependent components
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With