I don't find the correct class component implementation to be any simpler. You have to worry about the exact same issues (clearing on unmount, potentially canceling + calling again if a prop dependency changes), except now the implementation is spread across multiple lifecycle methods.
I've seen people reimplement it wrong in my codebase more than once, whereas no one gets useInterval incorrect because.. we can actually share that functionality across components now.
As I read this thread I feel helpless and not sure where to ask it, so excuse me that it is after your comment.
Why do react guys choose to fight these battles in the first place? We had a clear model, MVC (not web 1.0 mvc which left MC at the server-side, I mean fully-in-app one). All these setups and teardowns were done in “C” part, which didn’t depend on how exactly views are [re]arranged on the screen. And all interactions went through C, leaving little to no state in V (only very local ones, e.g. whether a combobox shows a popup currently, or is there a div-tooltip above a label). Then react broke in and told us that all we have is views/components and got a grip on their lifetimes, forcing us to jump through these hoops.
And that functional-async thing was not even the reason for that. React could have the same “off-screen” controllers with explicit lifetimes and setState() and friends, to be more testable and composable and all of that. But they chose to push all state to transient objects and then began to heroically solve the mess created by themselves in the first place.
I think I get what you mean (lmk if I don't). But there's a reason why libraries like redux can exist: React is _not_ a framework and doesn't impose many restrictions on how you choose to structure your application.
My current company, for example, enforces a pretty strict architecture where core application state lives "outside" of React [0]. What page we're on in our SPA, for example, is determined by state in our page store. We don't explicitly rely on component mounts to fetch data - instead, when we switch to a certain page via some action (like OpenUserPage), and _that_ fetches the data needed for that page. When we navigate away from the page, we call CloseUserPage, which unloads that data.
We try to push all business logic into discrete use case based actions. Like you mention, we use local component state strictly for view state (e.g. is this menu open?). We try to use hooks like useInterval sparingly (I think we only use it for things like updating relative timestamps every minute). We do use setInterval in business logic though, where it's simpler to reason about, for stuff like polling.
In an ideal world, there'd be very little need for complex hooks, where most components are strictly function components that only re-render when state that they're subscribed to in the store changes. But the real world is messier, and making a beautiful SPA with animations and whatnot often requires tricky view state logic with hooks. And of course, you have to be careful to not re-render components unnecessarily in performance critical situations where you're rendering a lot of stuff.
But I think the sort of high level separation you're describing can and does exist, and IMO striving for that separation makes it much easier to understand how data flows through the app, and obviates the need of digging into component lifecycles to manage the state of the app.
[0] At the end of the day, it does live in a top level context, but we don't interact with that context directly from child components.
But afaiu, with state living outside of react, components have to be updated by hand? Or at least have state to be duplicated in them. I mean, if a control/table gets a mutating event, you have to update both outside state and internal setState({input}) as well, otherwise input wouldn’t appear. And when outside state changes by itself, there should be a way to forceUpdate() with props or maybe a direct setState(). Am I right? At least MobX does exactly that behind its fancy object properties. I mean, I’m not saying react is restrictive, it’s just tedious and doesn’t even try to help if you claim ownership over your state.
you have to be careful to not re-render components unnecessarily in performance critical situations where you're rendering a lot of stuff
Interestingly mithril’s author, the library that I use in place of react, suggested that for humongous doms it’s better to split it into sub-apps. In short, you make a shallow component that mounts another entire “app” into its div on its mount, so that updates do not propagate downwards (the main app simply doesn’t know about subapps). Otoh, events propagate as expected due to regular dom. Personally I find mithril’s approach more outside-state compatible, if you don’t do funny tricks with event bubbling. If you’re not familiar with it, it’s the same hyperscript/vdom, but re-render()s everything after any event related to dom it controls, unless you tell it not to in a handler. That is fully out-of-your-way approach compatible with anything. Animations, jquery components, you name it. I don’t get why people choose react, which seems to be the opposite of that. What do you think I’m missing here?
And thanks for your time and the expanded reply!
Edit: frankly, I wouldn’t even bother with react and would just use what fits, but its presence is overwhelming in a sense of both community and job options. If there is a way to use it without abstraction pain, I’d like to understand it.
Most state libraries provide a mechanism by which you can "connect" components to parts of the state you care about, so that they automatically re-render when that part of the state changes. New props come in, and the components can decide what to do with them.
> Or at least have state to be duplicated in them. I mean, if a control/table gets a mutating event, you have to update both outside state and internal setState({input}) as well, otherwise input wouldn’t appear. And when outside state changes by itself, there should be a way to forceUpdate() with props or maybe a direct setState(). Am I right?
So this is where things get murky, regardless of what library you're using.
In the application I work on, our stores generally reflect the source of truth in the backend, and it's real-time so the stores can update literally whenever. This puts two-way data binding you see in other libraries out of the picture. So most of our forms use local state, because we only want the data store to be updated with the final result the end user actually saves. Interaction wise, it wouldn't make sense to submit it anyways until the user blurs it or explicitly saves it (plus it would be unnecessarily expensive to constantly be saving to the backend every keystroke).
And by keeping the state local, if an update comes in from the backend on the entity we're editing in the form, we can notify you that "Hey, someone else updated this!" while still keeping our form state, and letting us make the decision as to whether we want to overwrite it. But we could also choose to just override the form state if we so choose.. we could just look at the "connect"ed state we care about, and if it changes, overwrite the local state in the form. That's generally not desirable though from an interaction perspective.
> In short, you make a shallow component that mounts another entire “app” into its div on its mount, so that updates do not propagate downwards
We do the same thing in our application. Although the top level component technically re-renders when our data store changes (because it's stored in a top level context component), we just have memoized components beneath that take 0 props, which therefore don't re-render even when the parent re-renders.
And we re-use this same pattern through the application, creating separated subtrees that only re-render when data they care about changes.
So I think of our application like this: either user interaction occurs (like a form save), or the backend updates. This changes our data store (which tries to reflect the state on our backend). This update causes the components that care about that specific piece of information to re-render. Their children may choose to re-render or not. And then we wait for more interaction / backend changes. This is where the notion of "unidirectional data flow" comes from - it's basically just a tight one-way loop that drives the presentation of our application. Forms do muck this up a bit with their local state, but that's something I think _every_ application has to think about handling
I will admit I only learned frontend development / React properly at my current company (previous used a messy Backbone.js app), so there might be simpler / better ways at this point, but I find it very easy to reason about our application as a result of this pretty explicit data flow + our architectural constraints.
- lifecycle methods are clearly named and explicit. Hook lifecycle is implicit and you better have the whole doc in your head. It depends on this weird array, and have a few edge cases.
- you need useRef() to hold the interval ID for it to work with hooks. A fact you are unlikely to realize without a hard debugging session. While a simple attribute will suffice with a class.
- spreading accross methods is actually nice: people intertwine code for various steps easily in hooks, making a mess of things
The benefit of the hook version, of course, that the reusable version is very nice.
Fair disclosure: I'm definitely biased towards hooks because I feel like I "get" them and would never go back. So bear with me
> Hook lifecycle is implicit and you better have the whole doc in your head. It depends on this weird array, and have a few edge cases.
useEffect() will run after each render if you don't specify a dependency array. Otherwise, it will run only if any dependency in the array has === changed. That's pretty explicit to me (but I think you mean in terms of naming.. but I think one or two reads through the docs should make it explicit).
The return "cleanup" function is a bit odd at first, sure, but it's really just a function that will run _before_ the next effect runs.
The general recommendation is to not use the dependencies array at all unless necessary.
> you need useRef() to hold the interval ID for it to work with hooks. A fact you are unlikely to realize without a hard debugging session. While a simple attribute will suffice with a class.
useRef() in practice behaves almost exactly the same way as instance properties on class-based components, so I think of them as the same thing. useRef is even nicer for debugging IMO because their values are exposed in the React devtools, unlike instance properties.
> spreading accross methods is actually nice: people intertwine code for various steps easily in hooks, making a mess of things
I think having 5 features crammed into the same lifecycle method is far more of a mess than each feature handling their lifecycles independently. That's the point of hooks: you can wrap up functionality that's related to the same feature all in one place. IMO that's simpler to reason about and debug, but I can understand how that might trip other people up. It's sort of horizontal vs. vertical.
I've seen people reimplement it wrong in my codebase more than once, whereas no one gets useInterval incorrect because.. we can actually share that functionality across components now.