Is it possible to chain a React hook? If so, how?
A typical application of a hook would look like this:
const [inv, updateInventory] = useState([])
a = ["cheese", "bread", "apples"]
b = a.filter(isDairy)
updateInventory(b)
We can also do this, but it's not chained:
const [inv, updateInventory] = useState([])
a = ["cheese", "bread", "apples"]
updateInventory(a.filter(isDairy))
What I want is a chained hook in a functional style:
const [inv, updateInventory] = useState([])
a = ["cheese", "bread", "apples"]
a.filter(isDairy).updateInventory()
Can a hook can be modified to take state from this?
Proper usage would be:
updateInventory([...a, "cheddar"].quicksort().filter("cheese"))
But if you really want that chaining, look into how to edit the array prototype.
This is really not recommended, as that method will then be available on all arrays.
I think the underlying problem is you're not clear on what's actually happening with method chaining and possibly with hooks. The specific question:
Can a hook can be modified to take state from this?
doesn't really make sense. So let's break down why then come back at the end to how you could approach this.
For method chaining, let's try a simple example using two methods, .filter and .map, that have two important properties:
They actually return arrays (unlike .push, which returns the new length of the array); and
They actually exist on arrays (unlike .quicksort, which exists on neither an array nor the integer you were calling it on).
function isDairy(item) {
return ["cheese", "milk"].includes(item);
}
function getPrice(item) {
return { bread: 0.58, cheese: 0.80, apples: 0.47, milk: 1.01 }[item];
}
const inStock = ["bread", "cheese", "apples"];
inStock
.filter(isDairy)
.map((item) => ({ item, price: getPrice(item) }));
// => [{ item: "cheese", price: 0.8 }]
There's nothing particularly special happening here, each method you're calling returns a new array on which you can also call any method an array has. You could assign the intermediate steps and get the same result:
const filteredStock = stock.filter(isDairy);
// => ["cheese"]
const pricedFilteredStock = filteredStock.map((item) => ({ item, price: getPrice(item) }));
// => [{ item: "cheese", price: 0.8 }]
It is not the case that:
these are standalone functions (like in e.g. Python where you map(callable, iterable)); or
that the item.name syntax is doing anything beyond just accessing a property named name on the item.
If I tried to use the filter method as a standalone function:
filter(isDairy, inStock);
that would be a ReferenceError, or if I defined another function and tried to access it as if it was a prop on an array:
function allUppercase() {
return this.map((item) => item.toUpperCase());
}
inStock.allUppercase();
it would be a TypeError (because isStock.allUppercase is undefined and undefined isn't callable).
Note you could do allUppercase.bind(inStock)() (or the neater allUppercase.call(inStock)), though; JavaScript does have a means of setting this for a function.
When you use the useState hook, you're calling a function that returns an array containing two objects, and destructuring that array to two local variables:
const [thing, setThing] = useState(initialValue);
is equivalent to:
const result = useState(initialValue);
const thing = result[0];
const setThing = result[1];
The thing, setThing naming is just a convention; really, we're accessing those two objects (current value and setter function) by position. They don't have names of their own, you can do const [foo, bar] = useState("baz") (but... don't).
As the setter is a function you might be wondering whether you can use setThing.bind here, but if setThing is written to use this (I didn't look into the implementation, as it's not directly relevant), it's not going to be happy if you change what this is!
So this comes together when you try to do:
const [basket, setBasket] = useState([]);
// ^^^^^^^^^
inStock.filter(...).map(...).setBasket();
// ^^^^^^^^^
As with the example above, this is a TypeError because setBasket doesn't exist on the array returned by .map. The fact that the same "word" setBasket appears twice is totally irrelevant as far as JavaScript is concerned; one is a local variable and the other is a prop on an array, there's no connection between them.
.map(...) returns a new array, one that we didn't already have a reference to, so the only way to make this work is to ensure all arrays have a setBasket method, which means patching the prototype (as covered in adding custom functions into Array.prototype):
Object.defineProperty(Array.prototype, "setBasket", {
value () {
setBasket(this);
},
});
One problem here is that the function setBasket is accessed via a closure, so it needs to happen inside the component where the hook is defined, so it's going to get defined every time the component is rendered (or you're going to useEffect), which is a problem because you can't redefine that method as written...
But let's ignore that because the bigger problem is that every array in your app now has that method, even in contexts where it's not relevant. If you have multiple state hooks, as seems likely in any non-trivial app, your arrays are gaining lots of methods globally that are only for use in small local scopes.
A more feasible approach is to add a generic method that can be used to apply any hook (in fact any function) to an array:
Object.defineProperty(Array.prototype, "andCall", {
value (func) {
return func(this);
},
});
This can be added once, globally, and used to apply whatever hook is relevant:
inStock.filter(...).map(...).andCall(setBasket);
Note that if you're using TypeScript, you'd also have to add the definition to the global array type, e.g.:
declare global {
interface Array<T> {
andCall<S>(func: (arr: Array<T>) => S): S;
}
}
Related
I'm just doing a bit of refactoring and I was wondering if I have a bunch of useCallback calls that I want to group together, is it better do it as a simple hook that I would reuse in a few places?
The result would be
interface IUtils {
something(req: Something) : Result;
somethingElse(req: SomethingElse) : Result;
// etc...
}
So a plain hooks example would be:
export function useUtils() : IUtils {
// there's more but basically for this example I am just using one.
// to narrow the focus down, the `use` methods on this
// block are mostly getting data from existing contexts
// and they themselves do not have any `useEffect`
const authenticatedClient = useAuthenticatedClient();
// this is a method that takes some of the common context stuff like client
// or userProfile etc from above and provides a simpler API for
// the hook users so they don't have to manually create those calls anymore
const something = useCallback((req:SomethingRequest)=> doSomething(authenticatedClient), [authenticatedClient]
// there are a few of the above too.
return {
something
}
}
The other option was to create a context similar to the above
const UtilsContext = createContext<IUtils>({ something: noop });
export UtilsProvider({children}:PropsWithChildren<{}>) : JSX.Element {
const authenticatedClient = useAuthenticatedClient();
const something = useCallback((req:SomethingRequest)=> doSomething(authenticatedClient), [authenticatedClient]
const contextValue = useMemo({something}, [something]);
return <UtilsContext.Provider value={contextValue}>{children}</UtilsContext.Provider>
}
The performance difference between the two approaches are not really visible (since I can only test it in the device) even on the debugger and I am not sure how to even set it up on set up on jsben.ch.
Having it as just a simple hook is easier I find because I don't have to deal with adding yet another component to the tree, but even if I use it in a number of places I don't see any visible improvement but the devices could be so fast that it's moot. But what's the best practice in this situation?
So, we have an app with multiple resources, let's say we have Product, Cart, Whatever resources. For each of those resources you can create activities, the main idea here is that for each resource there is an endpoint to create/update those activities, which looks the same no matter the resource you are trying to update.
So in our app (React) we created a single form to create/update an activity, it looks the same no matter for which resource you want to create an activity for, same fields, same possible values. Therefore we have one single component instead of 3, and a common function that handles the api part.
Something like:
const { mutate } = useUniversalEditActivity(variant); // variant can be 'product', 'cart', 'whatever'
We call mutate when we want to submit the form.
Inside that hook, there is a simple map:
const variantMapper = {
product: {
serviceAction: updateProductActivity, // simple function that wraps fetch, does the network request
},
cart: {
serviceAction: updateCartActivity,
},
whatever: {
serviceAction: updateWhateverActivity,
},
};
// Using it like
const mutatingServiceAction = variantMapper[variant].serviceAction;
...
await mutatingServiceAction();
The body is typed as
type UniversalEditActivityBodyType =
| UpdateProductActivityRequestBody
| UpdateCartActivityRequestBody
| UpdateWhateverActivityRequestBody
Which works when all the properties are the same across the types, but the problem starts now when the BE changed the spec for the Whatever resource.
So, before the request body had a property which had 2 possible values, so it was typed like:
type UpdateProductActivityRequestBody = {
propertyWithIssues: 'a'| 'b';
}
All 3 looked the same, but the spec changed for the Whatever resource to:
type UpdateWhateverActivityRequestBody = {
propertyWithIssues: 'a'| 'b' | 'c' | 'd';
}
Adding 2 more possible values for the same property, now there is a difference on how they look and inside my generic function that handled all body types, now I get the Type '"a" | "b" | "c" | "d"' is not assignable to type '"a" | "b"'.
I kind of understand the error, but not sure how to fix it in order for my function to still work with all those 3 types when just the possible values on a single property is different between them.
I don't know if I explained as good as I should have, but it's a more complex question (I think), so I tried my best. Please also suggest a different title if you think it would better describe my problem. Thanks in advance.
UPDATE 1:
#chris-hamilton, the request is executed like this:
const resp = await mutatingServiceAction(id, activityId, payload);
This is where the issue happens, because payload is a union of all those 3 types, but now they have become incompatible.
Minimum reproducible example here: https://codesandbox.io/s/gallant-bessie-sxjc4x?file=/src/index.ts
I know in theory that the issue could be solved by doing something like:
if (variant === 'product') {
// directly use product function
}
...
But I have the feeling this can be something different, as they have the exact same structure, just one property can have different values.
This error is really just a big red flag for this design pattern. You've specified that these two parameters can be one of many types, meaning you can have any combination of variant and body. ie. this function would accept a variant of "product" and a body of UpdateCartActivityRequestBody. But clearly that is not how the function is meant to be used.
It's a question of what these updateActivity functions are doing, and how similar they are. It may be that the parameters of these functions can accept a common type:
type Body = {
propertyWithIssues: string;
}
function main(
variant: "product" | "cart" | "whatever",
body: Body
) {
const serviceAction = variantMapper[variant].serviceAction;
serviceAction(body); // no error
}
So you need to ask yourself, "do my functions need to know about all these specific properties?".
If the answer is no, then define a type with only the properties needed for those functions. If those properties are common for all functions then the design pattern is fine. The type may just be Object, it depends what the functions are doing.
If the answer is yes, then the design pattern is incorrect, and you shouldn't be coupling these service actions into a single function. You should just be calling updateWhateverActivity directly, with the correct parameter type. No need for this variantMapper object.
Maybe you have other reasons for implementing this pattern, but you'll need to give more details if that's the case.
I can see it maybe being the case that you have an object with variant and body and you don't know what they are until run time. In that case you will have to do type narrowing like you showed. But you should also be checking that the type of body actually matches variant or you're just asking for runtime errors. This is really what the error is trying to tell you.
if (variant === 'product') {
// somehow verify that body is of type UpdateProductActivityRequestBody
// directly use product function
}
In my use case I have an array of characters, each character has multiple builds, and each build has a weapons string, and artifacts string. I'm making a tool to select portions of each string and assign them to a value, e.g. assigning index 3-49 of weapons to a specific weapon.
const [characterIndices, setCharacterIndices] = useState<
{ builds: { weaponIndices: SE[]; artifactSetIndices: SE[] }[] }[]
>([
...characters.map((char) => {
return {
builds: [
...char.builds.map((_build) => {
return {
weaponIndices: [],
artifactSetIndices: [],
};
}),
],
};
}),
]);
The SE type is as follows:
type SE = { start: number; end: number; code: string };
//start and end are the respective start and end of selected text
//code is the specific artifact or weapon
The weaponIndices and artifactSetIndices basically hold the start and end of selected text in a readonly textarea.
I have a function to add a SE to either weaponIndices or artifactSetIndices:
const addSE = (
type: "weaponIndices" | "artifactSetIndices",
{ start, end, code }: SE,
characterIndex: number,
buildIndex: number
) => {
let chars = characterIndices;
chars[characterIndex].builds[buildIndex][type].push({ start, end, code });
setCharacterIndices((_prev) => chars);
console.log(characterIndices[characterIndex].builds[buildIndex][type]);
};
I think that using a console log after using a set function isn't recommended, but it does show what it's intended to the weaponIndices, or artifactSetIndices after an entry is added.
Passing the addSE function alongside characterIndices to a separate component, and using addSE, does print the respective indices after adding an entry, but the component's rendering isn't updated.
It only shows up when I "soft reload" the page, when updating the files during the create-react-app live reload via npm run start.
In case you are confused about what the data types are, I've made a github repo, at https://github.com/ChrisMGeo/ght-indexer/tree/main/src at src/data.json. That JSON file describes what the character data looks like, including the builds, and each build's weapons and artifacts(called artifact_sets in the JSON)
Looks to me you are not updating the state at all.
Here you are just storing the same object reference that you already have in state into a new variable chars.
let chars = characterIndices;
chars now holds reference to a same object as characterIndices.
Here you are mutating that same object
chars[characterIndex].builds[buildIndex][type].push({ start, end, code });
And here you are updating the state to the same object that is already in the state. Notice that no state update here occurs.
setCharacterIndices((_prev) => chars);
Object you have in state is mutated, but you did not "change" the value of the state, thus no component re-render.
What you could maybe do is create a copy of the object, mutate that and update the state. just change chars assignment like this:
let chars = {...characterIndices};
React often compares values using Object.is() only to a single level of nesting (the tested object and its children).
It will not re-render if the parent is found equal, or if all the children are found equal.
React then considers that nothing has changed.
In your implementation, even the first top-level check will immediately fail, since Object.is(before, after) will return true.
You could use an Immutable objects approach to eliminate this concern when setting a new state (either directly through spreading values or with a support library such as Immer).
For example instead of setting the values within the object...
myObj.key = newChildObj
...you would make a new object, which preserves many of the previous values.
myObj === {...myObj, key: newChildObj}
This means that every changed object tree is actually a different object (with only the bits that haven't changed being preserved).
To read more about this see https://javascript.plainenglish.io/the-effect-of-shallow-equality-in-react-85ae0287960c
can anyone explain to me please why and how this might happen:
I have a typescript app with Zustand state management.
Somewhere during the app I am updating certain elements by extracting them from the state and cloning via simple Object.Assign :
let elemToUpdate = Object.assign({},story?.content?.elementsData[nodeId]);
console.log(elemToUpdate);
if(elemToUpdate) {
if(elemToUpdate.title) elemToUpdate.title[editorLang] = newName;
else elemToUpdate.title = {[editorLang]:newName} as TextDictionary;
updateElement(nodeId,elemToUpdate);
}
Now the interesting part is on my first try the update goes through without fail, but the next object I am trying to update fails with the following message:
Tree.tsx:39 Uncaught TypeError: Cannot assign to read only property 'en' of object '#<Object>'
I can't understand WHY the first one comes through, but the second gets blocked.
(I know HOW to fix it, need to do deep clone, I just want to understand WHY)
Thanks
First, let's start from why some objects in your code are readonly. Based on what you described in the question, you use a Zustand state manager. Such managers traditionally wraps you stored data to readonly objects to prevent it's manual mutation (expecting, you will change the state only via built-in mechanisms), to guarantee data stability. So, if the story?.content?.elementsData[nodeId] is the Zustand state object, it self and all it's nested objects are converted to readonly.
Second, let's define, which objects will be blocked. I see at least two objects here: elemToUpdate: { ..., title: { [lang]: string }} (elemToUpdate and it's title). Both will be converted to readonly.
Third, you use Object.assign({}, ...) which creates a new object (new reference) and clones all properties of the source object. It happens only for first level of properties, no deep clone. So, as the title is a reference to another object, it will be cloned as is and in the new object it still leads to the existing { [lang]: string } object. There are several way to solve that: 1) deep clone as you mentioned; 2) manually clone title property, for instance {..., title: { ... elemToUpdate.title }} or via Object.assign
But I would suggest don't mutate you object this way. Probably, your entire algorithm has some architectural issues in general.
That is expected because in the first case you are not assigning value to the title you are only changing the value of the title property. In the second case, you are reassigning the value of the title property,
it's the read-only value you cant change it. Let's understand with a simple example
Javascript: Only for example not related to problem
const user = {
name: 'John',
}
user.name = "Pete"; // This works
const user = {
name: 'John',
}
user = { name: 'Pete'} // This doesn't work
Typescript:
const user: Readonly<{
a: {
name: string
}
}> = {
a:{ name: 'John',}
}
user.a.name = "Pete"; // This works
user.a = { name: 'John',} // not work
The same is happening there, Typescript does not check deep Readonly prop. check here
Following proper standards of keeping this.state immutable
const name = this.refs.name.value;
const names = [ ...this.state.names, name ];
//add new name to names array, and finally
this.setState({ names: names });
So I'm trying to understand what is setState trying to do here. Replace the old names array with the new updated names array?
Well { names: names } is just plain simple javascript object and what setState does is that the state names is being mutated to the new state which is the updated array.
What you are seeing there is the spread operator .... In ruby we have something very similar called splat operator. names becomes the result of concatenating all the elements of this.state.names and name into a new array. setState({names: names}) then updates the internal state of the component with the new names property.
One of its uses it's spreading array to use it's elements as separate arguments for a function call.
// Equivalent
console.log(...[1,2,3])
console.log(1,2,3)
// Usage in function definition
function asdf(qwer, ...uiop) {
console.log('NICE', qwer, uiop)
}
asdf(1,2,3,4);
// a declarative replacement for this procedural code
function asdf2() {
var qwer = arguments[0];
var uiop = Array.prototype.slice.call(arguments, 1, arguments.size);
console.log('UGLY', qwer, uiop);
}
asdf2(1,2,3,4)
Beware JSX is a syxntax extension and may not have all the latest es5 and es6 features.
I guess the answer is there.
The single colon in array sets the array value to the object.
facebook.github.io/react/docs/react-component.html#setstate