| | |

React Hook Form 08 ๐Ÿ“‹ Form State โ€” isDirty, isValid, touchedFields, and dirtyFields

โฑ๏ธ Estimated read: 14 min | |

The formState object is the form’s metadata layer, and the four properties that drive most of the UI feedback are isDirty, isValid, touchedFields, and dirtyFields. They are not independent states that the library computes separately; they are derived from the same internal sourceโ€”the comparison between the current values and the defaultValues, and the interaction history of each field. The derivation is the mechanism that keeps the four properties consistent with each other, and the defaultValues is the single source of truth that makes the comparison possible .

The distinction between isDirty and dirtyFields is the distinction between the form-level state and the field-level state. The isDirty is true when any field’s current value differs from its default, and it flips back to false when the field returns to the default. The dirtyFields is an object that marks the specific fields that are dirty, and it is the mechanism that lets the UI highlight the individual modified inputs rather than the whole form . The touchedFields is the interaction record, not the value record: a field is touched when it has received a focus and a blur, regardless of whether the value changed.

Key point: isDirty is the form-level boolean that is true when any field’s value differs from its defaultValues. dirtyFields is the per-field object that marks which fields differ. touchedFields is the per-field object that marks which fields have been blurred. isValid is true when there are no validation errors, and it is evaluated on mount when subscribed with mode: "onBlur". All four are derived from the internal form state and the defaultValues, and the formState proxy re-renders only the components that read the changed properties.


Why the four properties exist

The dirty problem. A form with an unsaved-changes warning needs to know whether the user has modified anything. The naive approach is to compare the current values against the initial values in the component, but the component would need to store the initial values and re-run the comparison on every render. The isDirty and the dirtyFields are the library’s mechanism that performs the comparison internally, and the defaultValues is the reference point that the comparison uses . The form-level boolean and the per-field object are the two granularities of the same comparison.

The touched problem. A validation error that appears before the user has finished typing the first character is a hostile UX. The touchedFields is the mechanism that lets the UI show the error only after the field has been blurred, and the mode: "onTouched" is the validation timing that pairs with it . The touched state is the interaction record, and it is the separate concern from the dirty state.

The valid problem. The isValid is the form-level boolean that is true when there are no errors. It is the mechanism that enables the submit button, and it is the state that the mode option affects. With the default mode: "onSubmit", the isValid can be false while the errors object is empty, because the validation has not run yet . With mode: "onBlur", the isValid is evaluated on mount when the component subscribes to it. The distinction is the mechanism that prevents the submit button from being enabled before the validation has run.

The derivation problem. The four properties are not stored as separate flags that the library sets independently. They are derived from the internal _formState and the defaultValues, and the derivation is the mechanism that keeps them consistent. The isDirty is true when the dirtyFields has at least one entry, and the dirtyFields is populated by the deep comparison between the current values and the defaults . The derivation is the reason the two cannot disagree, and the reason the defaultValues must be complete for the comparison to be accurate.

The subscription problem. The formState is a proxy that tracks which properties the component reads during the render. A component that reads isDirty does not re-render when the touchedFields changes, and a component that reads errors does not re-render when the isDirty changes . The proxy is the mechanism that keeps the four properties from causing the whole form to re-render on every change, and it is the reason the narrow destructuring is the recommended pattern.


a. The isDirty and dirtyFields properties

The isDirty is the form-level boolean. It is true when any field’s current value differs from its default, and it flips back to false when the field returns to the default value . The defaultValues is the reference point, and a missing field in the defaults makes the comparison unreliable.

// The isDirty and dirtyFields
import { useForm } from "react-hook-form";

type ProfileFormValues = {
  name: string;
  email: string;
};

function ProfileForm() {
  const {
    register,
    formState: { isDirty, dirtyFields },
    setValue,
  } = useForm<ProfileFormValues>({
    // The defaultValues is the reference point for the comparison.
    defaultValues: { name: "", email: "" },
  });

  // The isDirty is true when any field differs from the default.
  // The dirtyFields is an object with the modified fields.
  console.log("isDirty:", isDirty);       // false initially
  console.log("dirtyFields:", dirtyFields); // {}

  return (
    <form>
      <input {...register("name")} />
      <button type="button" onClick={() => setValue("name", "Ada")}>
        Set the name
      </button>
    </form>
  );
}

The setValue("name", "Ada") marks the name field as dirty, and the isDirty becomes true. The setValue("name", "") restores the default value, and the isDirty becomes false again . The comparison is the mechanism that makes the revert work, and the defaultValues is the value the comparison uses.

The dirtyFields is the per-field object. It marks the specific fields that are dirty, and the UI can use it to highlight the modified inputs.

// The dirtyFields highlighting
function NameField() {
  const {
    register,
    formState: { dirtyFields },
  } = useForm<ProfileFormValues>({
    defaultValues: { name: "", email: "" },
  });

  return (
    <div>
      <input
        {...register("name")}
        // The dirtyFields.name is true when the field is modified.
        style={{ borderColor: dirtyFields.name ? "orange" : "gray" }}
      />
    </div>
  );
}

The dirtyFields.name is the per-field flag, and the style is the mechanism that highlights the modified input. The dirtyFields does not necessarily represent the isDirty form state, because the fields are marked as dirty at the field level rather than the entire form . The two are related but not identical.

The defaultValues must be complete for the comparison to be accurate. A missing field in the defaults makes the library treat the field as always dirty, because the comparison has no reference point. The defaultValues is the mechanism that makes the dirty tracking reliable .


b. The touchedFields and isValid properties

The touchedFields is the interaction record. A field is touched when it has received a focus and a blur, regardless of whether the value changed . The object marks the specific fields that have been interacted with, and the UI can use it to show the validation error only after the user has left the field.

// The touchedFields and the error display
function EmailField() {
  const {
    register,
    formState: { errors, touchedFields },
  } = useForm<ProfileFormValues>({
    defaultValues: { name: "", email: "" },
    mode: "onTouched",
  });

  return (
    <div>
      <input {...register("email", { required: "The email is required." })} />
      {/* The error is shown only after the field is touched. */}
      {touchedFields.email && errors.email && (
        <p role="alert">{errors.email.message}</p>
      )}
    </div>
  );
}

The touchedFields.email is the interaction flag, and the mode: "onTouched" is the validation timing that runs the validation on blur and then on change after the first error . The combination is the mechanism that shows the error only after the user has had a chance to interact with the field.

The isValid is the form-level boolean. It is true when there are no errors, and it is the mechanism that enables the submit button.

// The isValid and the submit button
function SubmitButton() {
  const {
    formState: { isValid },
  } = useForm<ProfileFormValues>({
    defaultValues: { name: "", email: "" },
    mode: "onChange",
  });

  return (
    <button type="submit" disabled={!isValid}>
      Save
    </button>
  );
}

The isValid is evaluated on mount when the component subscribes to it with mode: "onBlur" . With the default mode: "onSubmit", the isValid can be false while the errors are empty, because the validation has not run yet. The mode is the mechanism that determines when the isValid is computed, and the choice is the UX decision.

The isValid is not computed while the form is disabled, and the setError immediately forces the isValid to false . The forced false is not derived from the validation, and it is overwritten the next time the validation runs. The distinction is the mechanism that lets the server error disable the submit button without waiting for the next validation.


Complete Example Session

// ============================================
// PART 1: THE IS DIRTY AND THE DIRTY FIELDS
// ============================================

import { useForm } from "react-hook-form";

type ProfileFormValues = {
  name: string;
  email: string;
};

function ProfileForm() {
  const {
    register,
    formState: { isDirty, dirtyFields },
    setValue,
  } = useForm<ProfileFormValues>({
    defaultValues: { name: "", email: "" },
  });

  // The isDirty is false initially.
  // The dirtyFields is an empty object.

  return (
    <form>
      <input {...register("name")} />
      <button type="button" onClick={() => setValue("name", "Ada")}>
        Set the name
      </button>
      {/* The isDirty becomes true when the name is set. */}
      {isDirty && <p>The form has unsaved changes.</p>}
    </form>
  );
}

// ============================================
// PART 2: THE DIRTY FIELDS HIGHLIGHTING
// ============================================

function NameField() {
  const {
    register,
    formState: { dirtyFields },
  } = useForm<ProfileFormValues>({
    defaultValues: { name: "", email: "" },
  });

  return (
    <input
      {...register("name")}
      // The dirtyFields.name is true when the field is modified.
      style={{ borderColor: dirtyFields.name ? "orange" : "gray" }}
    />
  );
}

// ============================================
// PART 3: THE TOUCHED FIELDS
// ============================================

function EmailField() {
  const {
    register,
    formState: { errors, touchedFields },
  } = useForm<ProfileFormValues>({
    defaultValues: { name: "", email: "" },
    mode: "onTouched",
  });

  return (
    <div>
      <input {...register("email", { required: "The email is required." })} />
      {/* The error is shown only after the field is touched. */}
      {touchedFields.email && errors.email && (
        <p role="alert">{errors.email.message}</p>
      )}
    </div>
  );
}

// ============================================
// PART 4: THE IS VALID
// ============================================

function SubmitButton() {
  const {
    formState: { isValid },
  } = useForm<ProfileFormValues>({
    defaultValues: { name: "", email: "" },
    mode: "onChange",
  });

  return (
    <button type="submit" disabled={!isValid}>
      Save
    </button>
  );
}

// ============================================
// PART 5: THE DEFAULT VALUES REFERENCE
// ============================================

// The defaultValues is the reference point for the dirty comparison.
// A missing field makes the comparison unreliable.
useForm<ProfileFormValues>({
  defaultValues: { name: "", email: "" },
});

// ============================================
// PART 6: THE RESET AND THE DIRTY STATE
// ============================================

function ResetForm() {
  const {
    register,
    formState: { isDirty, dirtyFields },
    reset,
  } = useForm<ProfileFormValues>({
    defaultValues: { name: "", email: "" },
  });

  return (
    <form>
      <input {...register("name")} />
      {/* The reset restores the default values and clears the dirty state. */}
      <button type="button" onClick={() => reset()}>
        Reset
      </button>
    </form>
  );
}

// ============================================
// PART 7: THE NARROW SUBSCRIPTION
// ============================================

// The component reads only isDirty.
// It re-renders only when isDirty changes.
function DirtyIndicator() {
  const {
    formState: { isDirty },
  } = useForm<ProfileFormValues>();

  return <>{isDirty && <p>The form has unsaved changes.</p>}</>;
}

// ============================================
// PART 8: THE FIELD STATE
// ============================================

import { getFieldState } from "react-hook-form";

// The getFieldState returns the per-field state.
const fieldState = getFieldState("name");
// The fieldState.isDirty is true when the field is modified.
// The fieldState.isTouched is true when the field is blurred.

// ============================================
// PART 9: THE CRITERIA MODE
// ============================================

// The criteriaMode does not affect the isDirty or the touchedFields.
// It affects the errors object.
useForm<ProfileFormValues>({
  criteriaMode: "all",
});

// ============================================
// PART 10: THE CHECKLIST
// ============================================

// [ ] The defaultValues is complete.
// [ ] The isDirty is used for the form-level indicator.
// [ ] The dirtyFields is used for the per-field highlight.
// [ ] The touchedFields is used for the error display timing.
// [ ] The isValid is used for the submit button.
// [ ] The mode is chosen for the validation timing.

The ten parts covered the isDirty and dirtyFields, the dirtyFields highlighting, the touchedFields, the isValid, the defaultValues reference, the reset, the narrow subscription, the getFieldState, the criteriaMode, and the checklist. The through-line is that the four properties are derived from the same source, and the subscription is the mechanism that keeps the re-renders proportional.


Quick Reference

The form state properties

PropertyTypeMeaning
isDirtybooleanTrue when any field differs from the default
dirtyFieldsobjectThe per-field dirty marks
touchedFieldsobjectThe per-field blur marks
isValidbooleanTrue when there are no errors

The mode and the isValid

ModeisValid on mountValidation timing
onSubmitfalseOn submit
onBlurEvaluatedOn blur
onChangeEvaluatedOn change
onTouchedEvaluatedOn blur, then on change

The reset options

OptionPreserves
keepDirtyThe isDirty and the dirtyFields
keepTouchedThe touchedFields
keepErrorsThe errors
keepValuesThe values

Best Practices

โœ… Do This:

// Provide the complete defaultValues
useForm({ defaultValues: { name: "", email: "" } });               // โœ…

// Use the touchedFields for the error timing
{touchedFields.email && errors.email && <p>{errors.email.message}</p>} // โœ…

// Read the formState narrowly
const { formState: { isDirty } } = useForm();                     // โœ…

โŒ Don’t Do This:

// Omit the defaultValues
useForm(); // the isDirty is unreliable                          // โŒ

// Read the formState conditionally
return <button disabled={!formState.isDirty} />; // the proxy does not subscribe // โŒ

Common Pitfalls

PitfallWhy It HappensFix
The isDirty is always trueThe defaultValues is missingSet the defaultValues
The isValid is always falseThe mode is onSubmit and the validation has not runSet the mode: "onChange"
The error shows before the interactionThe touchedFields is not checkedGuard the error with the touched state
The reset does not clear the dirty stateThe keepDirty is setRemove the option

Real-World Examples

1. The isDirty indicator

{isDirty && <p>Unsaved changes</p>}

2. The dirtyFields highlight

style={{ borderColor: dirtyFields.name ? "orange" : "gray" }}

3. The touchedFields error

{touchedFields.email && errors.email && <p>{errors.email.message}</p>}

4. The isValid submit button

<button disabled={!isValid}>Save</button>

5. The defaultValues

useForm({ defaultValues: { name: "", email: "" } })

6. The reset

reset();

7. The narrow subscription

const { formState: { isDirty } } = useForm();

8. The getFieldState

getFieldState("name").isDirty

9. The mode

useForm({ mode: "onTouched" })

10. The keepDirty

reset(undefined, { keepDirty: true })

Visual

The Derived State

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  THE FOUR PROPERTIES ARE DERIVED                             โ”‚
โ”‚                                                              โ”‚
โ”‚  The defaultValues                                           โ”‚
โ”‚         โ”‚                                                    โ”‚
โ”‚         โ–ผ                                                    โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”                                    โ”‚
โ”‚  โ”‚ The comparison      โ”‚  The current values vs the default โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜                                    โ”‚
โ”‚             โ”‚                                                โ”‚
โ”‚      โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”                                         โ”‚
โ”‚      โ–ผ             โ–ผ                                         โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”   โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”                                    โ”‚
โ”‚  โ”‚isDirty โ”‚   โ”‚dirtyFieldsโ”‚                                 โ”‚
โ”‚  โ”‚boolean โ”‚   โ”‚object  โ”‚                                    โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜   โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜                                    โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

The Touched Fields

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  THE TOUCHED FIELDS IS THE INTERACTION RECORD                โ”‚
โ”‚                                                              โ”‚
โ”‚  The user focuses the input                                  โ”‚
โ”‚         โ”‚                                                    โ”‚
โ”‚         โ–ผ                                                    โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”                                    โ”‚
โ”‚  โ”‚ The input is focusedโ”‚                                    โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜                                    โ”‚
โ”‚             โ”‚                                                โ”‚
โ”‚             โ–ผ                                                    โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”                                    โ”‚
โ”‚  โ”‚ The user blurs      โ”‚  The touchedFields.email is true   โ”‚
โ”‚  โ”‚ the input           โ”‚  The error is shown                โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜                                    โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

The Mode and the isValid

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  THE MODE AFFECTS THE isValid                                โ”‚
โ”‚                                                              โ”‚
โ”‚  mode: "onSubmit"                                            โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”                                    โ”‚
โ”‚  โ”‚ isValid: false      โ”‚  The validation has not run        โ”‚
โ”‚  โ”‚ errors: {}          โ”‚  The submit button is disabled     โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜                                    โ”‚
โ”‚                                                              โ”‚
โ”‚  mode: "onChange"                                            โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”                                    โ”‚
โ”‚  โ”‚ isValid: evaluated  โ”‚  The validation runs on change     โ”‚
โ”‚  โ”‚ errors: populated   โ”‚                                    โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜                                    โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Summary

ItemValue
isDirtyThe form-level boolean, true when any field differs from the default
dirtyFieldsThe per-field object of the modified fields
touchedFieldsThe per-field object of the blurred fields
isValidThe form-level boolean, true when there are no errors
The referenceThe defaultValues is the single source of truth
The subscriptionThe formState proxy re-renders only the read properties

Key takeaways:

  • The isDirty and the dirtyFields are the two granularities of the same comparison. The isDirty is the form-level boolean, and the dirtyFields is the per-field object. The defaultValues is the reference point, and a missing field makes the comparison unreliable .
  • The touchedFields is the interaction record, not the value record. A field is touched when it has received a focus and a blur, regardless of the value change. The touchedFields is the mechanism that lets the UI show the error only after the user has left the field .
  • The isValid is affected by the mode. With the default mode: "onSubmit", the isValid can be false while the errors are empty, because the validation has not run. The mode: "onBlur" evaluates the isValid on mount when the component subscribes to it .
  • The formState is a proxy. The component re-renders only when the properties it reads during the render change. The narrow destructuring is the mechanism that keeps the re-renders proportional, and the conditional read does not subscribe .
  • The reset clears the dirty and the touched state. The keepDirty, the keepTouched, and the keepErrors options are the mechanism that preserves the specific parts of the state across the reset .

Remember: The React Hook Form form state is the four derived properties. The isDirty and the dirtyFields are the comparison against the defaultValues, and the touchedFields is the interaction record. The isValid is the error-free state, and the mode is the timing. The formState proxy is the mechanism that keeps the re-renders proportional, and the defaultValues is the single source of truth.

Was this helpful?


Stop using slow, ad-bloated tool sites! ๐Ÿคฎ

๐Ÿ”Ž Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
โœ… Finance (Mortgage, Interest, Inflation)
โœ… Tech (Base64, JSON, Dev Suite, IP)
โœ… Health (BMI, BMR, TDEE)
โœ… Productivity (Timer, Workspace, QR)

โšก๏ธ Fast & Private
๐Ÿ”’ No data leaves your device
๐Ÿ’Ž 100% Free

๐Ÿ”— Use it now: https://tools.kandz.me
๐Ÿ”– Bookmark itโ€”youโ€™ll need it later!